diff --git a/Gemfile b/Gemfile index 7864adc..1989ff4 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' git_source(:github) do |repo_name| - repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/") + repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/') "https://github.com/#{repo_name}.git" end @@ -22,6 +22,7 @@ gem 'grape-entity', '~> 0.6.1' gem 'grape-swagger', '~> 0.27.3' gem 'grape-swagger-entity', '~> 0.2.1' gem 'jwt', '~> 2.0.0' +gem 'hashie-forbidden_attributes' # Codecoverage gem 'coveralls', require: false @@ -29,21 +30,21 @@ gem 'coveralls', require: false group :development, :test do gem 'dotenv-rails', '~> 2.2.1' - gem 'byebug', platforms: %i[mri mingw x64_mingw] - gem 'pry-byebug' + gem 'byebug', platforms: %i[mri mingw x64_mingw] + gem 'pry-byebug' - # Testing - gem 'factory_bot_rails', '~> 4.0' - gem 'json_matchers', '~> 0.7.0' - gem 'rspec-rails', '~> 3.6.0' - gem 'rspec_junit_formatter', '~> 0.3.0', require: false - gem 'webmock', '~> 3.0.1', require: false + # Testing + gem 'factory_bot_rails', '~> 4.0' + gem 'json_matchers', '~> 0.7.0' + gem 'rspec-rails', '~> 3.6.0' + gem 'rspec_junit_formatter', '~> 0.3.0', require: false + gem 'webmock', '~> 3.0.1', require: false - gem 'rubocop', '~> 0.49.1', require: false - gem 'rubocop-checkstyle_formatter', '~> 0.4.0', require: false - gem 'rubocop-rspec', '~> 1.16.0', require: false + gem 'rubocop', '~> 0.49.1', require: false + gem 'rubocop-checkstyle_formatter', '~> 0.4.0', require: false + gem 'rubocop-rspec', '~> 1.16.0', require: false - gem 'simplecov', '~> 0.15.1', require: false + gem 'simplecov', '~> 0.15.1', require: false end group :development do @@ -55,4 +56,4 @@ group :development do end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] \ No newline at end of file +gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/Gemfile.lock b/Gemfile.lock index 23a4cbf..7e74695 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,6 +98,9 @@ GEM grape-entity (>= 0.5.0) grape-swagger (>= 0.20.4) hashdiff (0.3.7) + hashie (3.5.6) + hashie-forbidden_attributes (0.1.1) + hashie (>= 3.0) http-cookie (1.0.3) domain_name (~> 0.5) i18n (0.9.0) @@ -272,6 +275,7 @@ DEPENDENCIES grape-entity (~> 0.6.1) grape-swagger (~> 0.27.3) grape-swagger-entity (~> 0.2.1) + hashie-forbidden_attributes json_matchers (~> 0.7.0) jwt (~> 2.0.0) listen (>= 3.0.5, < 3.2) diff --git a/app/api/api/api.rb b/app/api/api/api.rb index 73fcb65..c6ab6ae 100644 --- a/app/api/api/api.rb +++ b/app/api/api/api.rb @@ -2,6 +2,15 @@ module API class API < Grape::API format :json + HEADERS = { + Authorization: { + description: 'JWT - Validates your identity', + required: true + } + }.freeze + + helpers API::AuthHelper + get '/' do { version: `git rev-parse --short HEAD`.strip } end @@ -10,6 +19,7 @@ class API < Grape::API mount API::Locations mount API::Events mount API::SignUp + mount API::Auth add_swagger_documentation( mount_path: '/swagger_doc', diff --git a/app/api/api/auth.rb b/app/api/api/auth.rb new file mode 100644 index 0000000..25dd775 --- /dev/null +++ b/app/api/api/auth.rb @@ -0,0 +1,17 @@ +module API + class Auth < Grape::API + resource :auth do + desc 'Authorize user and return JWT token' do + end + params do + requires :email, type: String, desc: 'Email adress of user', allow_blank: false + requires :password, type: String, desc: 'Password of the user', allow_blank: false + end + post '/session' do + token = AuthorizationService.perform(params[:email], params[:password]) + error!("['401'] Unauthorized user might not exists", :unauthorized) unless token + { token: token } + end + end + end +end diff --git a/app/api/api/auth_helper.rb b/app/api/api/auth_helper.rb new file mode 100644 index 0000000..e37d17e --- /dev/null +++ b/app/api/api/auth_helper.rb @@ -0,0 +1,17 @@ +module API + module AuthHelper + def current_user + @current_user ||= User.find_by(id: decoded_token.user_id) + end + + private + + def decoded_token + @decoded_token ||= OpenStruct.new(TokenProvider.decode(token).first) + end + + def token + headers['Authorization'] + end + end +end diff --git a/app/api/api/locations.rb b/app/api/api/locations.rb index 8f1dfdb..8c8b6e3 100644 --- a/app/api/api/locations.rb +++ b/app/api/api/locations.rb @@ -7,20 +7,21 @@ class Locations < Grape::API get '/' do present Location.all, with: Entities::Location end - desc 'Creates a single location' do success Entities::Location + headers API::HEADERS end params do requires :name, type: String, desc: 'Name for the location' optional :description, type: String, desc: 'Description for the location' - requires :latitude, type: Float, desc: 'Latitude for the location' - requires :longitude, type: Float, desc: 'Latitude for the location' + requires :latitude, type: Float, desc: 'Latitude for the location', values: -90.0..+90.0 + requires :longitude, type: Float, desc: 'Latitude for the location', values: -180.0..+180.0 requires :image_url, type: String, desc: 'Path to image for the location' end post '/' do location = current_user.locations.create(params) error!("['422'] Unprocessable entity", 422) unless location.persisted? + present location, with: Entities::Location end desc 'Returns a single location for given id' do success Entities::Location @@ -33,6 +34,24 @@ class Locations < Grape::API error!("['404'] Resource not found", 404) unless location present location, with: Entities::Location end + desc 'Updates a given location' do + success Entities::Location + headers API::HEADERS + end + params do + requires :name, type: String, desc: 'Name for the location' + optional :description, type: String, desc: 'Description for the location' + requires :latitude, type: Float, desc: 'Latitude for the location', values: -90.0..+90.0 + requires :longitude, type: Float, desc: 'Latitude for the location', values: -180.0..+180.0 + requires :image_url, type: String, desc: 'Path to image for the location' + end + put '/:id' do + location = current_user.locations.find_by(id: params[:id]) + error!("['404'] Resource not found", 404) unless location + location.update_attributes(params) + error!("['422'] Unprocessable entity", 422) unless location.valid? + present location, with: Entities::Location + end end end end diff --git a/app/api/api/sign_up.rb b/app/api/api/sign_up.rb index 55403d0..e649ce9 100644 --- a/app/api/api/sign_up.rb +++ b/app/api/api/sign_up.rb @@ -11,7 +11,7 @@ class SignUp < Grape::API post '/' do user = User.find_by(email: params[:email]) error!('[400] User with email already exists', 400) if user - User.create(params) + user = User.create(params) token = TokenProvider.issue_token(user) { token: token } end diff --git a/app/services/authorization_service.rb b/app/services/authorization_service.rb new file mode 100644 index 0000000..5a4407b --- /dev/null +++ b/app/services/authorization_service.rb @@ -0,0 +1,27 @@ +class AuthorizationService + def self.perform(email, password) + new(email, password).perform + end + + def initialize(email, password) + @email = email + @password = password + end + + def perform + return unless authorize? + TokenProvider.issue_token(user) + end + + private + + attr_reader :email, :password + + def user + @user ||= User.find_by(email: email) + end + + def authorize? + user && user.authenticate(password) + end +end diff --git a/app/services/token_provider.rb b/app/services/token_provider.rb index ec60c5b..1175d56 100644 --- a/app/services/token_provider.rb +++ b/app/services/token_provider.rb @@ -5,18 +5,24 @@ class TokenProvider def self.issue_token(user) payload = { user_id: user.id, + user_email: user.email, exp: 1.day.from_now.to_i } JWT.encode payload, SECRET, 'HS256' end def self.valid?(token) - begin - JWT.decode token, SECRET, true, algorithm: 'HS256' - rescue JWT::DecodeError, JWT::ImmatureSignature - # IF this token is invalid, the JWT gem will raise this error and by caching it in this block - # the returning valud should be false - false - end + JWT.decode token, SECRET, true, algorithm: 'HS256' + rescue JWT::DecodeError, JWT::ImmatureSignature + # IF this token is invalid, the JWT gem will raise this error and by + # catching it in this block + # the returning valud should be false + false end -end \ No newline at end of file + + def self.decode(token) + JWT.decode token, SECRET, true, algorithm: 'HS256' + rescue JWT::DecodeError, JWT::ImmatureSignature + {} + end +end diff --git a/db/migrate/20171029162844_change_column_default.rb b/db/migrate/20171029162844_change_column_default.rb new file mode 100644 index 0000000..1fcac9c --- /dev/null +++ b/db/migrate/20171029162844_change_column_default.rb @@ -0,0 +1,5 @@ +class ChangeColumnDefault < ActiveRecord::Migration[5.1] + def change + change_column_default :events, :archived, false + end +end diff --git a/db/migrate/20171029163211_not_null_latitude_longitude.rb b/db/migrate/20171029163211_not_null_latitude_longitude.rb new file mode 100644 index 0000000..a38f6d9 --- /dev/null +++ b/db/migrate/20171029163211_not_null_latitude_longitude.rb @@ -0,0 +1,8 @@ +class NotNullLatitudeLongitude < ActiveRecord::Migration[5.1] + def change + change_column_null(:locations, :latitude, false) + change_column_null(:locations, :longitude, false) + change_column_null(:locations, :name, false) + change_column_default(:locations, :archived, false) + end +end diff --git a/db/migrate/20171029163428_not_null_events.rb b/db/migrate/20171029163428_not_null_events.rb new file mode 100644 index 0000000..00dec80 --- /dev/null +++ b/db/migrate/20171029163428_not_null_events.rb @@ -0,0 +1,7 @@ +class NotNullEvents < ActiveRecord::Migration[5.1] + def change + change_column_null(:events, :name, false) + change_column_null(:events, :start_time, false) + change_column_null(:events, :archived, false) + end +end diff --git a/db/schema.rb b/db/schema.rb index c5b3a0f..f60a1e2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,20 +10,20 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171028191027) do +ActiveRecord::Schema.define(version: 20171029163428) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" create_table "events", force: :cascade do |t| - t.string "name" + t.string "name", null: false t.string "description" - t.datetime "start_time" + t.datetime "start_time", null: false t.string "sound_cloud_url" t.string "sound_cloud_user_id" t.bigint "location_id" t.bigint "user_id" - t.boolean "archived" + t.boolean "archived", default: false, null: false t.string "image_url" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -32,12 +32,12 @@ end create_table "locations", force: :cascade do |t| - t.string "name" + t.string "name", null: false t.string "description" - t.float "latitude" - t.float "longitude" + t.float "latitude", null: false + t.float "longitude", null: false t.bigint "user_id" - t.boolean "archived" + t.boolean "archived", default: false t.string "image_url" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/spec/api/api/auth_spec.rb b/spec/api/api/auth_spec.rb new file mode 100644 index 0000000..40a96f6 --- /dev/null +++ b/spec/api/api/auth_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +describe API::Auth do + let(:user) { create :user } + let(:params) do + { + email: user.email, + password: user.password + } + end + + context 'User exists and email and password are correct' do + before do + post '/auth/session', params: params, headers: nil + end + it 'returns a with succes http status' do + expect(response).to have_http_status(:success) + end + + it 'matches schema' do + expect(response).to match_response_schema('token') + end + end + + context 'User exists but password is incorrect' do + let(:params) do + { + email: user.email, + password: user.password + ' wrong' + } + end + + it 'returns unauthorized' do + post '/auth/session', params: params, headers: nil + expect(response).to have_http_status(:unauthorized) + end + end + + context 'User does not exists' do + let(:params) do + { + email: 'Do not exist', + password: ' wrong' + } + end + + it 'returns unauthorized' do + post '/auth/session', params: params, headers: nil + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/spec/api/api/locations_spec.rb b/spec/api/api/locations_spec.rb index 65ce716..768ad70 100644 --- a/spec/api/api/locations_spec.rb +++ b/spec/api/api/locations_spec.rb @@ -27,4 +27,98 @@ expect(response).to have_http_status(404) end end + + describe 'POST /location' do + let(:params) do + { + name: 'Test location', + description: 'My test desc.', + latitude: 54.13, + longitude: 8.12, + image_url: 'path/to/image.jpg' + } + end + + user_authenticated + + before do + post '/locations', params: params, headers: @headers + end + + context 'valid params' do + it 'returns with http status created' do + expect(response).to have_http_status(:created) + end + + it 'return matches schema' do + expect(response).to match_response_schema('locations/location') + end + + it 'adds a location' do + expect do + post '/locations', params: params, headers: @headers + end.to change(Location, :count).by(1) + end + end + + context 'latitude is missing' do + let(:params) do + { + name: 'Test location', + description: 'My test desc.', + longitude: 8.12, + image_url: 'path/to/image.jpg' + } + end + + it 'returns with an error' do + expect(response).to have_http_status(400) + end + end + + context 'longitude is missing' do + let(:params) do + { + name: 'Test location', + description: 'My test desc.', + latitude: 8.12, + image_url: 'path/to/image.jpg' + } + end + + it 'returns with an error' do + expect(response).to have_http_status(400) + end + end + + context 'name is missing' do + let(:params) do + { + description: 'My test desc.', + longitude: 53.123, + latitude: 8.12, + image_url: 'path/to/image.jpg' + } + end + + it 'returns with an error' do + expect(response).to have_http_status(400) + end + end + + context 'image_url is missing' do + let(:params) do + { + name: 'My test name.', + description: 'My test desc.', + longitude: 53.123, + latitude: 8.12, + } + end + + it 'returns with an error' do + expect(response).to have_http_status(400) + end + end + end end diff --git a/spec/api/api/sign_up_spec.rb b/spec/api/api/sign_up_spec.rb new file mode 100644 index 0000000..1a581f5 --- /dev/null +++ b/spec/api/api/sign_up_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +describe API::SignUp do + let(:params) do + { + email: 'test@teasdf.de', + password: 'mafsdfa', + password_confirmation: 'mafsdfa' + } + end + + context 'Valid params' do + before do + post '/sign_up', params: params, headers: nil + end + + it 'returns with http status code created' do + expect(response).to have_http_status(:created) + end + + it 'returns with json schema' do + expect(response).to match_response_schema('token') + end + end + + context 'User already exists' do + let(:user) { create :user } + let(:params) do + { + email: user.email, + password: 'mafsdfa', + password_confirmation: 'mafsdfa' + } + end + + it 'returns http status code 400' do + post '/sign_up', params: params, headers: nil + expect(response).to have_http_status(400) + end + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index b3febe1..ab5a6fb 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -1,9 +1,43 @@ require 'rails_helper' describe Event, type: :model do - describe 'validations' do + let(:user) { create :user } + let(:valid_params) do + attributes_for(:event).merge( + user: user, + name: 'test event', + location_id: 1, + start_time: DateTime.now, + description: 'test drescription', + image_url: './test_image.jpg' + ) + end - end + let(:event) { Event.new(event_params) } + + context 'when name is not given' do + let(:event_params) { valid_params.merge(name: nil) } + + it 'is invalid' do + expect(event).to be_invalid + end + end + context 'When location is not given' do + let(:event_params) { valid_params.merge(location_id: nil) } + + it 'is invalid' do + expect(event).to be_invalid + end + end + + context 'When start date is nil' do + let(:event_params) { valid_params.merge(start_time: nil) } + + it 'is invalid' do + expect(event).to be_invalid + end + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 40734a2..4979ecc 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -27,6 +27,8 @@ # If you are not using ActiveRecord, you can remove this line. ActiveRecord::Migration.maintain_test_schema! +Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } + RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_path = "#{::Rails.root}/spec/fixtures" @@ -51,6 +53,8 @@ # https://relishapp.com/rspec/rspec-rails/docs config.infer_spec_type_from_file_location! + config.extend ApiMacros + # Filter lines from Rails gems in backtraces. config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: diff --git a/spec/services/authorization_service_spec.rb b/spec/services/authorization_service_spec.rb new file mode 100644 index 0000000..fe035f4 --- /dev/null +++ b/spec/services/authorization_service_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +describe AuthorizationService do + let(:service) { described_class.new(email, password) } + let(:email) { 'mytest@email.de' } + let(:password) { 'my_password' } + + context 'When user does not exists' do + it 'Returns nil as result' do + expect(service.perform).to be_nil + end + + it 'does not calls the token provider' do + allow(TokenProvider).to receive(:issue_token) + service.perform + expect(TokenProvider).not_to have_received(:issue_token) + end + end + + context 'When user exists' do + let(:user) { create :user } + let(:email) { user.email } + let(:password) { user.password } + + it 'Returns a token' do + expect(service.perform).not_to be_nil + end + + it 'Calls the token provider' do + allow(TokenProvider).to receive(:issue_token) + service.perform + expect(TokenProvider).to have_received(:issue_token) + end + end + + context 'When user exists but password is wrong' do + let(:user) { create :user } + let(:email) { user.email } + let(:password) { user.password + 'will_be_wrong' } + + it 'Returns nil as result' do + expect(service.perform).to be_nil + end + + it 'does not call the token provider' do + allow(TokenProvider).to receive(:issue_token) + service.perform + expect(TokenProvider).not_to have_received(:issue_token) + end + end +end diff --git a/spec/support/api/schemas/locations/location.json b/spec/support/api/schemas/locations/location.json index bcfe114..46e5592 100644 --- a/spec/support/api/schemas/locations/location.json +++ b/spec/support/api/schemas/locations/location.json @@ -27,7 +27,7 @@ "longitude": { "type": "number" }, - "desciption": { + "image_url": { "type": [ "null", "string" diff --git a/spec/support/api/schemas/token.json b/spec/support/api/schemas/token.json new file mode 100644 index 0000000..f6a6f70 --- /dev/null +++ b/spec/support/api/schemas/token.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/spec/support/api_macros.rb b/spec/support/api_macros.rb new file mode 100644 index 0000000..8baee29 --- /dev/null +++ b/spec/support/api_macros.rb @@ -0,0 +1,8 @@ +module ApiMacros + def user_authenticated + before do + user = create :user + @headers = { Authorization: TokenProvider.issue_token(user) } + end + end +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb index a048ba4..851b913 100644 --- a/spec/support/factory_bot.rb +++ b/spec/support/factory_bot.rb @@ -3,4 +3,4 @@ RSpec.configure do |config| config.include FactoryBot::Syntax::Methods -end \ No newline at end of file +end