diff --git a/Gemfile b/Gemfile index b9a71077..56de71d2 100644 --- a/Gemfile +++ b/Gemfile @@ -96,4 +96,5 @@ end group :test do gem 'database_cleaner' gem 'factory_bot_rails' + gem 'timecop' end diff --git a/Gemfile.lock b/Gemfile.lock index c46f3333..f8e3fd74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,7 +246,7 @@ GEM nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) - rack (2.0.8) + rack (2.2.2) rack-proxy (0.6.5) rack rack-test (1.1.0) @@ -367,6 +367,7 @@ GEM thor (1.0.1) thread_safe (0.3.6) tilt (2.0.10) + timecop (0.9.1) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -452,6 +453,7 @@ DEPENDENCIES sqlite3 sucker_punch terminal-table + timecop turbolinks (~> 5) uglifier (>= 1.3.0) validates_hostname diff --git a/app/controllers/api/v1/invitades_controller.rb b/app/controllers/api/v1/invitades_controller.rb index 6ab0a1b7..d086798b 100644 --- a/app/controllers/api/v1/invitades_controller.rb +++ b/app/controllers/api/v1/invitades_controller.rb @@ -28,7 +28,7 @@ module Api expires = 30.minutes cookies.encrypted[site] = { httponly: true, - secure: true, + secure: !Rails.env.test?, expires: expires, same_site: :none, value: { diff --git a/app/controllers/api/v1/posts_controller.rb b/app/controllers/api/v1/posts_controller.rb index 7cb9f08e..1729de78 100644 --- a/app/controllers/api/v1/posts_controller.rb +++ b/app/controllers/api/v1/posts_controller.rb @@ -26,8 +26,15 @@ module Api # No procesar nada más si ya se aplicaron todos los filtros return if performed? + usuarie = Site::Author.new name: 'Anon', email: "anon@#{site.hostname}" + service = PostService.new(params: params, + site: site, + usuarie: usuarie) + + service.create_anonymous + # Redirigir a la URL de agradecimiento - redirect_to params[:redirect_to] + redirect_to params[:redirect_to] || site.url end private @@ -39,7 +46,7 @@ module Api def cookie_is_valid? unless cookies.encrypted[site_id] && cookies.encrypted[site_id]['expires'] > Time.now.to_i - render html: nil, status: :no_content + render html: 'cookie_invalid', status: :no_content end end @@ -50,9 +57,11 @@ module Api # TODO: Pensar una forma de redirigir al origen sin vaciar el # formulario para que le usuarie recargue la cookie. def valid_authenticity_token_in_cookie? - unless valid_authenticity_token? session, cookies.encrypted[site_id] - render html: nil, status: :no_content + if valid_authenticity_token? session, cookies.encrypted[site_id]['csrf'] + return end + + render html: 'token_invalid', status: :no_content end # El sitio existe y soporta colaboracion anónima @@ -62,7 +71,7 @@ module Api def site_exists_and_is_anonymous? _, anon = site_anon_pair - render html: nil, status: :no_content + render html: 'site_not_anon', status: :no_content unless anon end # El navegador envía la URL del sitio en el encabezado Origin, @@ -70,9 +79,9 @@ module Api def site_is_origin? site, = site_anon_pair - unless "https://#{site}" === request.headers['Origin'] - render html: nil, status: :no_content - end + return if request.headers['Origin'] == "https://#{site}" + + render html: 'site_not_origin', status: :no_content end # Solo soy un atajo diff --git a/app/models/site/author.rb b/app/models/site/author.rb new file mode 100644 index 00000000..28df9b22 --- /dev/null +++ b/app/models/site/author.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Site + Author = Struct.new :email, :name, keyword_init: true +end diff --git a/app/services/post_service.rb b/app/services/post_service.rb index 85a40ee8..125d4eeb 100644 --- a/app/services/post_service.rb +++ b/app/services/post_service.rb @@ -8,7 +8,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # # @return Post def create - self.post = site.posts(lang: params[:post][:lang] || I18n.locale) + self.post = site.posts(lang: lang) .build(layout: params[:post][:layout]) post.usuaries << usuarie params[:post][:draft] = true if site.invitade? usuarie @@ -20,6 +20,19 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do post end + # Crear un post anónimo, con opciones más limitadas + def create_anonymous + # XXX: Confiamos en el parámetro de idioma porque estamos + # verificándolos en Site#posts + self.post = site.posts(lang: lang) + .build(layout: params[:post][:layout]) + # Los artículos anónimos siempre son borradores + params[:post][:draft] = true + + commit(action: :created) if post.update(anon_post_params) + post + end + def update post.usuaries << usuarie params[:post][:draft] = true if site.invitade? usuarie @@ -82,6 +95,13 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do params.require(:post).permit(post.params) end + # Eliminar metadatos internos + def anon_post_params + post_params.delete_if do |k, _| + %w[date slug order uuid].include? k + end + end + def lang params[:post][:lang] || I18n.locale end diff --git a/db/seeds.rb b/db/seeds.rb index fd303779..01b0afef 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -16,10 +16,12 @@ licencias.each do |l| licencia.update l end -YAML.safe_load(File.read('db/seeds/sites.yml')).each do |site| - site = Site.find_or_create_by name: site['name'] +unless Rails.env.test? + YAML.safe_load(File.read('db/seeds/sites.yml')).each do |site| + site = Site.find_or_create_by name: site['name'] - site.update licencia: Licencia.first, design: Design.first, - title: site.name, description: 'x' * 50, - deploys: [DeployLocal.new] + site.update licencia: Licencia.first, design: Design.first, + title: site.name, description: 'x' * 50, + deploys: [DeployLocal.new] + end end diff --git a/test/controllers/api/v1/invitades_controller_test.rb b/test/controllers/api/v1/invitades_controller_test.rb new file mode 100644 index 00000000..4be9524a --- /dev/null +++ b/test/controllers/api/v1/invitades_controller_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Api + module V1 + class PostsControllerTest < ActionDispatch::IntegrationTest + setup do + @rol = create :rol + @site = @rol.site + @usuarie = @rol.usuarie + + @site.update_attribute :colaboracion_anonima, true + end + + teardown do + @site.destroy + end + + test 'primero hay que pedir una cookie' do + get v1_site_invitades_cookie_url(@site) + + assert cookies[@site.name] + assert cookies['_sutty_session'] + end + + test 'solo si el sitio existe' do + site = SecureRandom.hex + + get v1_site_invitades_cookie_url(site_id: site) + + assert_not cookies[site] + assert_not cookies['_sutty_session'] + end + + test 'solo si el sitio tiene colaboracion anonima' do + @site.update_attribute :colaboracion_anonima, false + + get v1_site_invitades_cookie_url(@site) + + assert_not cookies[@site.name] + assert_not cookies['_sutty_session'] + end + end + end +end diff --git a/test/controllers/api/v1/posts_controller_test.rb b/test/controllers/api/v1/posts_controller_test.rb new file mode 100644 index 00000000..3cf2841c --- /dev/null +++ b/test/controllers/api/v1/posts_controller_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Api + module V1 + class PostsControllerTest < ActionDispatch::IntegrationTest + setup do + @rol = create :rol + @site = @rol.site + @usuarie = @rol.usuarie + + @site.update_attribute :colaboracion_anonima, true + end + + teardown do + @site.destroy + end + + test 'no se pueden enviar sin cookie' do + post v1_site_posts_url(@site), params: { + post: { + title: SecureRandom.hex, + description: SecureRandom.hex + } + } + + posts = @site.posts.size + @site = Site.find(@site.id) + + assert_equal posts, @site.posts.size + assert_response :no_content + end + + test 'no se pueden enviar a sitios que no existen' do + site = SecureRandom.hex + + get v1_site_invitades_cookie_url(site_id: site) + + post v1_site_posts_url(site_id: site), + headers: { cookies: cookies }, + params: { + post: { + title: SecureRandom.hex, + description: SecureRandom.hex + } + } + + assert_response :no_content + end + + test 'antes hay que pedir una cookie' do + assert_equal 2, @site.posts.size + + get v1_site_invitades_cookie_url(@site) + + post v1_site_posts_url(@site), + headers: { + cookies: cookies, + origin: "https://#{@site.name}" + }, + params: { + post: { + title: SecureRandom.hex, + description: SecureRandom.hex + } + } + + # XXX: No tenemos reload + @site = Site.find @site.id + + assert_equal 3, @site.posts.size + assert_response :redirect + end + + test 'no se pueden enviar algunos valores' do + uuid = SecureRandom.uuid + date = Date.today + 2.days + slug = SecureRandom.hex + title = SecureRandom.hex + order = (rand * 100).to_i + + get v1_site_invitades_cookie_url(@site) + + post v1_site_posts_url(@site), + headers: { + cookies: cookies, + origin: "https://#{@site.name}" + }, + params: { + post: { + title: title, + description: SecureRandom.hex, + uuid: uuid, + date: date, + slug: slug, + order: order + } + } + + # XXX: No tenemos reload + @site = Site.find @site.id + p = @site.posts.find_by title: title + + assert_not_equal uuid, p.uuid.value + assert_not_equal slug, p.slug.value + assert_not_equal order, p.order.value + assert_not_equal date, p.date.value + end + + test 'las cookies tienen un vencimiento interno' do + assert_equal 2, @site.posts.size + + get v1_site_invitades_cookie_url(@site) + + Timecop.freeze(Time.now + 31.minutes) do + post v1_site_posts_url(@site), + headers: { + cookies: cookies, + origin: "https://#{@site.name}" + }, + params: { + post: { + title: SecureRandom.hex, + description: SecureRandom.hex + } + } + end + + @site = Site.find @site.id + assert_response :no_content + assert_equal 2, @site.posts.size + end + end + end +end