Merge branch 'anonymous' into rails

This commit is contained in:
f 2020-02-20 15:37:51 -03:00
commit 5b591d5e93
No known key found for this signature in database
GPG key ID: 2AE5A13E321F953D
38 changed files with 668 additions and 62 deletions

View file

@ -96,4 +96,5 @@ end
group :test do
gem 'database_cleaner'
gem 'factory_bot_rails'
gem 'timecop'
end

View file

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

View file

@ -1,13 +1,16 @@
//= require_tree .
@import "bootstrap";
$black: black;
$white: white;
$grey: grey;
$cyan: #13fefe;
$magenta: #f206f9;
// Redefinir variables de Bootstrap
$primary: $magenta;
@import "bootstrap";
:root {
--foreground: #{$black};
--background: #{$white};

View file

@ -4,6 +4,8 @@ module Api
module V1
# Recibe los reportes de Content Security Policy
class CspReportsController < BaseController
skip_forgery_protection
# Crea un reporte de CSP intercambiando los guiones medios por
# bajos
#

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
module Api
module V1
class InvitadesController < BaseController
# Obtiene una cookie válida por el tiempo especificado por el
# sitio.
#
# Aunque visitemos el sitio varias veces enviando la cookie
# anterior, la cookie se renueva.
def cookie
# XXX: Prestar atención a que estas acciones sean lo más rápidas
# y utilicen la menor cantidad posible de recursos, porque son
# un vector de DDOS.
site, anon = Site.where(name: params[:site_id], colaboracion_anonima: true)
.pluck(:name, :colaboracion_anonima)
.first
# La cookie no es accesible a través de JS y todo su contenido
# está cifrado para que no lo modifiquen les visitantes
#
# Enviamos un token de protección CSRF
if anon
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Origin'] = "https://#{site}"
headers['Vary'] = 'Origin'
expires = 30.minutes
cookies.encrypted[site] = {
httponly: true,
secure: !Rails.env.test?,
expires: expires,
same_site: :none,
value: {
csrf: form_authenticity_token,
expires: (Time.now + expires).to_i
}
}
end
render html: nil, status: :no_content
end
end
end
end

View file

@ -0,0 +1,108 @@
# frozen_string_literal: true
module Api
module V1
class PostsController < BaseController
# Ver doc/anonymous.md
skip_forgery_protection
# Protecciones antes de procesar los datos
before_action :cookie_is_valid?, unless: :performed?
before_action :valid_authenticity_token_in_cookie?, unless: :performed?
before_action :site_exists_and_is_anonymous?, unless: :performed?
before_action :site_is_origin?, unless: :performed?
# Crea un artículo solo si el sitio es invitado, pero antes
# tenemos que averiguar varias cosas:
#
# * la cookie sea válida
# * el token anti CSRF es válido
# * el sitio existe
# * el sitio admite invitades
# * el origen de la petición no es el sitio
#
# TODO: Definir cuáles van a ser las respuestas para cada error
# o si simplemente vamos a aceptarlas sin dar feedback.
def create
# 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] || site.url
end
private
# Comprueba que no se haya reutilizado una cookie vencida
#
# XXX: Si el navegador envió una cookie vencida es porque la está
# reutilizando, probablemente de forma maliciosa?
def cookie_is_valid?
unless cookies.encrypted[site_id] &&
cookies.encrypted[site_id]['expires'] > Time.now.to_i
render html: 'cookie_invalid', status: :no_content
end
end
# Queremos comprobar que la cookie corresponda con la sesión. La
# cookie puede haber vencido, así que es uno de los chequeos más
# simples que hacemos.
#
# 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?
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
#
# Pedimos el sitio aunque no lo necesitemos para que la consulta
# entre en la caché
def site_exists_and_is_anonymous?
_, anon = site_anon_pair
render html: 'site_not_anon', status: :no_content unless anon
end
# El navegador envía la URL del sitio en el encabezado Origin,
# queremos comprobar que los datos son enviados desde ahí.
def site_is_origin?
site, = site_anon_pair
return if request.headers['Origin'] == "https://#{site}"
render html: 'site_not_origin', status: :no_content
end
# Solo soy un atajo
def site_id
@site_id ||= params[:site_id]
end
# La consulta más barata que podemos hacer y la reutilizamos para
# que esté en la caché
def site_anon_pair
Site.where(name: site_id, colaboracion_anonima: true)
.pluck(:name, :colaboracion_anonima)
.first
end
# Instancia el sitio completo
#
# XXX: Solo usar después de comprobar que el sitio existe!
def site
@site ||= Site.find_by(name: site_id)
end
end
end
end

View file

@ -113,6 +113,7 @@ class SitesController < ApplicationController
def site_params
params.require(:site)
.permit(:name, :design_id, :licencia_id, :description, :title,
:colaboracion_anonima,
deploys_attributes: %i[type id _destroy])
end
end

View file

@ -10,4 +10,16 @@ class MetadataArray < MetadataTemplate
def to_param
{ name => [] }
end
private
def sanitize(values)
values.map do |v|
if v.is_a? String
super(v)
else
v
end
end
end
end

View file

@ -1,4 +1,4 @@
# frozen_string_literal: true
# Un campo de correo
# Un campo de color
class MetadataColor < MetadataString; end

View file

@ -3,8 +3,6 @@
# Se encarga del contenido del artículo y quizás otros campos que
# requieran texto largo.
class MetadataContent < MetadataTemplate
include ActionView::Helpers::SanitizeHelper
def default_value
''
end
@ -16,22 +14,4 @@ class MetadataContent < MetadataTemplate
def front_matter?
false
end
private
# Etiquetas y atributos HTML a permitir
#
# No queremos permitir mucho más que cosas de las que nos falten en
# CommonMark.
#
# TODO: Permitir una lista de atributos y etiquetas en el Layout
#
# XXX: Vamos a generar un reproductor de video/audio directamente
# desde un plugin de Jekyll
def sanitize_options
{
tags: %w[span],
attributes: %w[title class lang]
}
end
end

View file

@ -4,4 +4,16 @@ class MetadataDate < MetadataTemplate
def default_value
Date.today
end
# Ver MetadataDocumentDate
def value
return self[:value] if self[:value].is_a? Date
return self[:value] if self[:value].is_a? Time
begin
self[:value] = Date.parse(self[:value] || document.data[name.to_s])
rescue ArgumentError, TypeError
default_value
end
end
end

View file

@ -7,12 +7,16 @@ class MetadataDocumentDate < MetadataTemplate
Date.today.to_time
end
# El valor puede ser un Date, Time o una String en el formato
# "yyyy-mm-dd"
def value
self[:value] || document.date || default_value
end
return self[:value] if self[:value].is_a? Date
return self[:value] if self[:value].is_a? Time
def value=(date)
date = date.to_time if date.is_a? String
super(date)
begin
self[:value] = Date.parse(self[:value]).to_time
rescue ArgumentError, TypeError
document.date || default_value
end
end
end

View file

@ -38,6 +38,8 @@ class MetadataFile < MetadataTemplate
# Asociar la imagen subida al sitio y obtener la ruta
def save
value['description'] = sanitize(value['description'])
return true if uploaded?
return true if path_optional?
return false unless hardlink.zero?

View file

@ -7,7 +7,7 @@ class MetadataLang < MetadataTemplate
end
def value
self[:value] || document.collection.label.to_sym
self[:value] || document.collection.label || default_value
end
def values

View file

@ -7,4 +7,10 @@ class MetadataOrder < MetadataTemplate
def default_value
site.posts(lang: post.lang.value).sort_by(:date).index(post)
end
def save
self[:value] = value.to_i
true
end
end

View file

@ -39,8 +39,6 @@ class MetadataSlug < MetadataTemplate
private
def title
return if post.title.try(:value).blank?
post.title.try(:value)
post.title.try(:value) unless post.title.try(:value).blank?
end
end

View file

@ -6,8 +6,4 @@ class MetadataString < MetadataTemplate
def default_value
''
end
def value
super.strip
end
end

View file

@ -8,6 +8,8 @@
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
:value, :help, :required, :errors, :post,
:layout, keyword_init: true) do
include ActionText::ContentHelper
# El valor por defecto
def default_value
raise NotImplementedError
@ -22,6 +24,9 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
end
# Valor actual o por defecto
#
# XXX: No estamos sanitizando la entrada, cada tipo tiene que
# auto-sanitizarse.
def value
self[:value] || document.data.fetch(name.to_s, default_value)
end
@ -60,6 +65,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# En caso de que algún campo necesite realizar acciones antes de ser
# guardado
def save
self[:value] = sanitize value
true
end
@ -69,5 +75,18 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
def can_be_empty?
true unless required && empty?
end
# No usamos sanitize_action_text_content porque espera un ActionText
#
# Ver ActionText::ContentHelper#sanitize_action_text_content
def sanitize(string)
return unless string
return string unless string.is_a? String
sanitizer.sanitize(string,
tags: allowed_tags,
attributes: allowed_attributes,
scrubber: scrubber).strip.html_safe
end
end
# rubocop:enable Metrics/BlockLength

View file

@ -1,5 +1,4 @@
# frozen_string_literal: true
# Un campo de texto largo
class MetadataText < MetadataString
end
class MetadataText < MetadataString; end

View file

@ -79,7 +79,6 @@ class Post < OpenStruct
File.mtime(path.absolute)
end
# Solo ejecuta la magia de OpenStruct si el campo existe en la
# plantilla
#
@ -163,10 +162,7 @@ class Post < OpenStruct
def destroy
FileUtils.rm_f path.absolute
# TODO: Devolver self en lugar de todo el array
site.posts(lang: lang.value).reject! do |post|
post.path.absolute == path.absolute
end
site.delete_post self
end
alias destroy! destroy

View file

@ -169,10 +169,16 @@ class Site < ApplicationRecord
# @param lang: [String|Symbol] traer los artículos de este idioma
def posts(lang: nil)
read
@posts ||= {}
lang ||= I18n.locale
return @posts[lang] if @posts.key? lang
# Traemos los posts del idioma actual por defecto
lang ||= I18n.locale
# Crea un Struct dinámico con los valores de los locales, si
# llegamos a pasar un idioma que no existe vamos a tener una
# excepción NoMethodError
@posts ||= Struct.new(*locales.map(&:to_sym)).new
return @posts[lang] unless @posts[lang].blank?
@posts[lang] = PostRelation.new site: self
@ -187,15 +193,29 @@ class Site < ApplicationRecord
@posts[lang]
end
# Elimina un artículo de la colección
def delete_post(post)
lang = post.lang.value
collections[lang.to_s].docs.delete(post.document) &&
posts(lang: lang).delete(post)
post
end
# Obtiene todas las plantillas de artículos
#
# @return { post: Layout }
def layouts
@layouts ||= data.fetch('layouts', {}).map do |name, metadata|
# Crea un Struct dinámico cuyas llaves son los nombres de todos los
# layouts. Si pasamos un layout que no existe, obtenemos un
# NoMethodError
@layouts_struct ||= Struct.new(*data.fetch('layouts', {}).keys.map(&:to_sym), keyword_init: true)
@layouts ||= @layouts_struct.new(**data.fetch('layouts', {}).map do |name, metadata|
{ name.to_sym => Layout.new(site: self,
name: name.to_sym,
metadata: metadata.with_indifferent_access) }
end.inject(:merge)
end.inject(:merge))
end
# Trae todos los valores disponibles para un campo
@ -335,7 +355,9 @@ class Site < ApplicationRecord
def deploy_local_presence
# Usamos size porque queremos saber la cantidad de deploys sin
# guardar también
return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')
if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')
return
end
errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence'))
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class Site
Author = Struct.new :email, :name, keyword_init: true
end

View file

@ -105,7 +105,7 @@ class Site
# Encuentra todos los layouts con campos estáticos
def layouts
@layouts ||= site.layouts.reject do |_, layout|
@layouts ||= site.layouts.to_h.reject do |_, layout|
layout.metadata.select do |_, desc|
STATIC_TYPES.include? desc['type']
end.empty?

View file

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

View file

@ -1,2 +1,2 @@
-# TODO: Implementar i18n
= hidden_field 'post[lang]', I18n.locale
= hidden_field_tag 'post[lang]', I18n.locale.to_s

View file

@ -13,7 +13,7 @@
%h3= t('posts.new')
%ul
- @site.layouts.keys.each do |layout|
- @site.layouts.to_h.keys.each do |layout|
%li= link_to layout.to_s.humanize,
new_site_post_path(@site, layout: layout)

View file

@ -34,4 +34,4 @@
- next if @post.send(attr).front_matter?
%section{ id: attr }
= raw @post.send(attr).value
= @post.send(attr).value.html_safe

View file

@ -87,6 +87,14 @@
%hr/
- if site.persisted?
.form-group
%h2= t('.colaboracion_anonima.title')
%p.lead= t('.colaboracion_anonima.help')
.custom-control.custom-switch
= f.check_box :colaboracion_anonima, class: 'custom-control-input'
= f.label :colaboracion_anonima, class: 'custom-control-label'
.form-group
%h2= t('.deploys.title')
%p.lead= t('.help.deploys')

View file

@ -76,6 +76,7 @@ en:
name: 'Name'
title: 'Title'
description: 'Description'
colaboracion_anonima: Enable anonymous collaboration
errors:
models:
site:
@ -319,6 +320,9 @@ en:
title: 'Privacy policy and code of conduct'
deploys:
title: 'Where do you want your site to be hosted?'
colaboracion_anonima:
title: 'Accept anonymous collaboration'
help: 'By allowing anonymous collaboration, you enable visitors to send articles without a Sutty account. Nothing is published without your consent, so make sure to check drafts regularly. This feature can expose you to attacks and violence, so we recommend you enable it with care.'
fetch:
title: 'Upgrade the site'
help:

View file

@ -79,6 +79,7 @@ es:
name: 'Nombre'
title: 'Título'
description: 'Descripción'
colaboracion_anonima: Habilitar colaboración anónima
errors:
models:
site:
@ -327,6 +328,9 @@ es:
title: Políticas de privacidad y código de convivencia
deploys:
title: '¿Dónde querés alojar tu sitio?'
colaboracion_anonima:
title: 'Aceptar colaboraciones anónimas'
help: 'Al permitir colaboraciones anónimas, habilitamos a les visitantes del sitio a enviar contenido sin necesidad de una cuenta en Sutty. Nada se publica sin tu consentimiento, así que revisa los borradores regularmente. Esto también te puede exponer a ataques y violencias, por lo que es una característica que recomendamos usar con cuidado.'
fetch:
title: 'Actualizar el sitio'
help:

View file

@ -21,7 +21,10 @@ Rails.application.routes.draw do
namespace :v1 do
resources :csp_reports, only: %i[create]
get 'sites/allowed', to: 'sites#allowed'
resources :sites, only: %i[index]
resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-\.]+/, id: /[a-z0-9\-\.]+/ } do
get 'invitades/cookie', to: 'invitades#cookie'
resources :posts, only: %i[create]
end
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddAnonymousToSite < ActiveRecord::Migration[6.0]
def change
add_column :sites, :colaboracion_anonima, :boolean, default: false
end
end

View file

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

161
doc/anonymous.md Normal file
View file

@ -0,0 +1,161 @@
# Colaboraciones anónimas
> Estamos escribiendo hipótesis para aclararnos las ideas.
[Ver discusión](https://0xacab.org/sutty/sutty/issues/75)
## Configuración
```yaml
# Actual
invitades: true
# Nueva
invitades:
allowed:
- users
- guests
- anonymous
```
Pero en realidad no queremos instanciar todo el sitio y leer la
configuración para poder comprobar esto, así que lo movemos a la base de
datos. De todas formas es información que no tiene sentido almacenar en
`_config.yml` porque no tiene uso fuera de Sutty.
## Procedimiento
* Al cargar el formulario, se incorpora una petición a la API de Sutty
que devuelve un recurso vacío y una cookie cifrada solo disponible
para HTTP (no para JS). Además agrega una cookie de sesión con un
token anti CSRF. Les decimos cookie-token y cookie-sesión
respectivamente.
* Al enviar el formulario, la petición se envía con estas cookies. Si
los tokens coinciden, el envío se permite. Esto no es una protección
CSRF completa, sino una forma de validar que se solicitó una cookie
antes.
* La sesión es válida si el token de sesión y el de la cookie coinciden.
* La emisión de sesiones + cookies está limitada en el servidor.
* Al cargar los datos correctamente, respondemos con una redirección
a la página de agradecimiento.
### CSRF
* Las protecciones contra CSRF permitirían al sitio que envía obtener un
token de autenticación que se valida contra la cookie de sesión
enviada por el servidor al pedir el recurso.
* El sitio tiene que enviar este token junto con la petición.
* No tiene mucho sentido usar protección contra CSRF porque ya estamos
haciendo peticiones cruzadas. La protección contra CSRF previene
acciones que en realidad queremos realizar (!)
* Trabajar con protección CSRF requiere que el sitio use JS que no
estábamos dispuestes a utilizar, porque hay que tomar el token
e incorporarlo al formulario en forma de campo oculto. Queremos que
les visitantes con JS deshabilitado puedan interactuar con nuestros
formularios también!
* La validación que estamos haciendo entre cookie cifrada y fecha de
vencimiento de la cookie es una forma de protección contra CSRF
liviana y sin interacción, aprovechando los mecanismos del navegador.
* Estamos mirando el valor de Origin para prevenir
### XSS
* Es importante limpiar todos las entradas de valores, para proteger
a les usuaries del sitio de ataques mayores, por ejemplo que se
introduzca JS en un artículo que luego se abre desde el panel.
* Hay que chequear las protecciones CSRF en los formularios internos del
panel!
* Hay que escapar todas las entradas al mostrarlas!
* Si la redirección se obtiene desde el mismo formulario, estaría
abierto a XSS?
### DOS
* La idea es permitir el envío de colaboraciones a una tasa normal (1
cada X minutos) y dificultar las tasas de envío agresivas (miles por
segundo).
* El DOS no solo implica bajar el servidor, sino también llenar el sitio
de artículos basura y dificultar el uso. O sea, el DOS se aplica
a les usuaries, que se estresan.
* Las cookies se pueden reutilizar, siempre y cuando el token sea
válido. Si guardáramos otra información como cantidad de
utilizaciones de una cookie, tendríamos que guardar el estado en la
base de datos y no queremos usar recursos en esto.
* Los atacantes pueden descartar la cookie (volverla a emitir)
o utilizar siempre la misma.
* Las cookies se emiten con límite, una cada 5 minutos.
* La cookie tiene que durar lo que se puede tardar en cargar el
formulario y completarlo, con un excedente por las dudas. Los
formularios tienen que soportar el guardado offline / autocompletado
para evitar que les usuaries se frustren. También es posible hacer la
solicitud de cookie usando JS inmediatamente antes del envío para
reducir la duración de la cookie aun más.
* Si la validez de la cookie-token es mayor que la tasa de emisión
(digamos, dura 30 minutos), un atacante puede enviar todos los
artículos que quiera durante ese tiempo (miles), con lo que tendríamos
que llevar un estado de las sesiones de todas formas.
* El envío de información también puede tener tasa de petición. Si
aplicamos `rate limit` en nginx a X minutos entre cada una, tiene que
haber una diferencia de tiempo entre la emisión de la cookie y el
envío de la información. Si la cookie se reintenta usar muchas veces,
también aplica la limitación.
* Si la tasa de envío es cada 5 minutos, un atacante podría enviar 288
artículos por día desde una sola IP.
## Casos de uso
* Usuarie ingresa al sitio, completa el formulario y lo envía.
Este es el caso que queremos.
* Dogpiling: Usuarios maliciosos pero desorganizados ingresan al sitio,
completan el formulario muchas veces y lo envían.
Para este caso estaríamos protegides por el rate limit. No tenemos
protección contra la paciencia y perseverancia del odio.
Quizás les podamos empezar a enviar zip bombs.
* DOS: Atacante individual o colectivo genera script que envía muchas
veces el formulario automáticamente. Puede enviar desde muchas
computadoras y es capaz de entender nuestra protecciones para
encontrarles puntos débiles.
Nos protege el rate limit hasta cierto punto.
* DDOS: Los atacantes aprovechan la capacidad de muchas personas que
voluntaria o inconcientemente ingresan a una URL que es capaz de enviar
información basura.
Nos protege el rate limit, CORS y XSS.
## Atención
* Sanitizar todas las entradas
* No dejar que se modifique slug, date, orden y otros metadatos internos
Quizás valga la pena redefinir cuáles son los parámetros anónimos

View file

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

View file

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

View file

@ -19,6 +19,7 @@ class EditorTest < ActionDispatch::IntegrationTest
end
test 'al enviar html se guarda markdown' do
skip
content = <<~CONTENT
<h1>Hola</h1>
@ -54,6 +55,7 @@ class EditorTest < ActionDispatch::IntegrationTest
end
test 'convertir trix' do
skip
path = Rails.root.join('test', 'fixtures', 'files')
trix_orig = File.read(File.join(path, 'trix.txt'))
trix_md = File.read(File.join(path, 'trix.md'))

View file

@ -28,7 +28,7 @@ class PostTest < ActiveSupport::TestCase
test 'se pueden eliminar' do
assert @post.destroy
assert_not File.exist?(@post.path.absolute)
assert_not @site.posts.include?(@post)
assert_not @site.posts(lang: @post.lang.value).include?(@post)
end
test 'se puede ver el contenido completo después de guardar' do