25 Commits

Author SHA1 Message Date
8903ae2624 Merge pull request 'Fix XMPP API POST request' (#17) from bugfix/faraday_post into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #17
2020-12-13 13:17:57 +00:00
26e9073674 Fix XMPP API POST request
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Faraday does not turn hashes into JSON by itself apparently.
2020-12-13 14:07:25 +01:00
73a89c2601 Merge pull request 'Add missing port number to ejabberd API base URL' (#16) from bugfix/ejabberd_http_port into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2020-12-13 12:57:34 +00:00
7d4dee17b7 Add missing port number to ejabberd API base URL
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-12-13 13:54:33 +01:00
602ca6ee94 Merge pull request 'Exchange XMPP contacts when invitee signs up' (#13) from feature/automatic_xmpp_roster into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2020-12-09 20:52:14 +00:00
69fc1ca57e Add production dotenv config
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-12-08 20:34:13 +01:00
ee72a32c7e Exchange XMPP contacts when invitee signs up
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2020-12-08 19:16:08 +01:00
8a0d89ef60 Add webmock gem 2020-12-08 18:16:41 +01:00
54af949c7d Add faraday for HTTP requests 2020-12-08 18:16:41 +01:00
6dac732a7f Move invitation invalidation to service 2020-12-08 17:52:53 +01:00
e8c1a6066a Move user db creation to service 2020-12-08 17:39:54 +01:00
44fadb12d6 Merge pull request 'Update link to Chat service' (#11) from chore/update_service_link into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #11
2020-12-04 15:18:49 +00:00
533452469b Update link to Chat service
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
The wiki page has been moved and improved for new users.
2020-12-04 16:15:17 +01:00
efe168b205 Merge pull request 'Sign up for new account via invitation' (#9) from feature/signup_from_invite into master
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #9
2020-12-04 12:30:28 +00:00
5b6d6bbd00 Explain ApplicationService
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-12-04 13:29:07 +01:00
458b585cdb Check off invitation signup feature in README
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2020-12-03 15:14:13 +01:00
f651289410 Add mailer host config for test
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
So Devise can build URLs in confirmation emails
2020-12-03 14:59:21 +01:00
7ca91cf882 Don't run caching steps on CI when not master or PR
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2020-12-03 14:56:11 +01:00
022094ce51 Add feature spec for whole signup process
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2020-12-03 14:50:02 +01:00
2a2b0a90dc Validate email address properly 2020-12-03 14:49:37 +01:00
e44535daee Don't use deprecated method 2020-12-03 14:49:16 +01:00
c8ccb418b2 Add indexes for invitations table 2020-12-03 14:49:02 +01:00
a792d66c90 Show unused invitations list 2020-12-03 14:48:43 +01:00
f7e48ad3a6 Accept non-existing terms
Legal how does it work
2020-12-03 14:47:57 +01:00
8a7d809b92 Add scopes for invitations 2020-12-03 14:04:58 +01:00
25 changed files with 356 additions and 55 deletions

View File

@@ -12,6 +12,9 @@ steps:
restore: true
mount:
- vendor
when:
branch:
- master
- name: rspec
image: guildeducation/rails:2.7.1-12.19.0
commands:
@@ -30,6 +33,9 @@ steps:
rebuild: true
mount:
- vendor
when:
branch:
- master
volumes:
- name: cache

View File

@@ -1,8 +1 @@
LDAP_HOST=192.168.33.10
LDAP_PORT=389
#
# Production LDAP server:
#
# LDAP_HOST=ldap.kosmos.org
# LDAP_PORT=636
# LDAP_USE_TLS=true
EJABBERD_API_URL='https://xmpp.kosmos.org/api'

1
.env.production Normal file
View File

@@ -0,0 +1 @@
EJABBERD_API_URL='https://xmpp.kosmos.org:5443/api'

1
.env.test Normal file
View File

@@ -0,0 +1 @@
EJABBERD_API_URL='http://xmpp.example.com/api'

View File

@@ -21,13 +21,15 @@ gem 'jbuilder', '~> 2.7'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.4.2', require: false
gem 'dotenv-rails', groups: [:development, :test]
gem 'dotenv-rails'
gem 'warden'
gem 'devise'
gem 'devise_ldap_authenticatable'
gem 'net-ldap'
gem 'faraday'
group :development, :test do
# Use sqlite3 as the database for Active Record
gem 'sqlite3', '~> 1.4'
@@ -51,6 +53,7 @@ group :test do
gem 'factory_bot_rails'
gem 'capybara'
gem 'database_cleaner'
gem 'webmock'
end
group :production do

View File

@@ -73,6 +73,8 @@ GEM
regexp_parser (~> 1.5)
xpath (~> 3.2)
concurrent-ruby (1.1.7)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.6)
database_cleaner (1.8.5)
devise (4.7.3)
@@ -95,9 +97,12 @@ GEM
factory_bot_rails (6.1.0)
factory_bot (~> 6.1.0)
railties (>= 5.0.0)
faraday (0.17.0)
multipart-post (>= 1.2, < 3)
ffi (1.13.1)
globalid (0.4.2)
activesupport (>= 4.2.0)
hashdiff (0.4.0)
i18n (1.8.5)
concurrent-ruby (~> 1.0)
jbuilder (2.10.1)
@@ -126,6 +131,7 @@ GEM
mini_portile2 (2.4.0)
minitest (5.14.2)
msgpack (1.3.3)
multipart-post (2.1.1)
net-ldap (0.16.3)
nio4r (2.5.4)
nokogiri (1.10.10)
@@ -191,6 +197,7 @@ GEM
rspec-mocks (~> 3.9)
rspec-support (~> 3.9)
rspec-support (3.10.0)
safe_yaml (1.0.5)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.4.0)
@@ -228,6 +235,10 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.6.0)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (4.3.0)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
@@ -251,6 +262,7 @@ DEPENDENCIES
devise_ldap_authenticatable
dotenv-rails
factory_bot_rails
faraday
jbuilder (~> 2.7)
letter_opener
letter_opener_web
@@ -268,6 +280,7 @@ DEPENDENCIES
tzinfo-data
warden
web-console (>= 3.3.0)
webmock
webpacker (~> 4.0)
BUNDLED WITH

View File

@@ -10,9 +10,9 @@ credentials, invites, donations, etc..
* [x] Reset account password when logged in, via reset email
* [x] Log in with admin permissions
* [x] View LDAP users as admin
* [x] Sign up for a new account via invitation
* [ ] List my donations
* [ ] Invite new users from your account
* [ ] Sign up for a new account via invitation
* [ ] Sign up for a new account by donating upfront
* [ ] Sign up for a new account via proving contributions (via cryptographic signature)
* [ ] ...

View File

@@ -25,4 +25,11 @@ form {
.actions {
margin-top: 2rem;
}
.accept-terms {
margin-top: 2rem;
font-size: 0.85rem;
line-height: 1.5em;
color: #888;
}
}

View File

@@ -6,7 +6,8 @@ class InvitationsController < ApplicationController
# GET /invitations
def index
@invitations = current_user.invitations
@invitations_unused = current_user.invitations.unused
@invitations_used = current_user.invitations.used
end
# GET /invitations/a-random-invitation-token

View File

@@ -94,17 +94,15 @@ class SignupController < ApplicationController
end
def complete_signup
@user.save!
session[:new_user] = nil
session[:validation_error] = nil
CreateAccount.call(
username: @user.cn,
domain: "kosmos.org",
email: @user.email,
password: @user.password
password: @user.password,
invitation: @invitation
)
@invitation.update_attributes invited_user_id: @user.id,
used_at: DateTime.now
end
end

View File

@@ -0,0 +1,15 @@
require 'mail'
module EmailValidatable
extend ActiveSupport::Concern
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
begin
a = Mail::Address.new(value)
rescue Mail::Field::ParseError
record.errors[attribute] << (options[:message] || "is not a valid address")
end
end
end
end

View File

@@ -2,10 +2,16 @@ class Invitation < ApplicationRecord
# Relations
belongs_to :user
# Validations
validates_presence_of :user
# Hooks
before_create :generate_token
# Scopes
scope :unused, -> { where(used_at: nil) }
scope :used, -> { where.not(used_at: nil) }
private
def generate_token

View File

@@ -1,10 +1,13 @@
class User < ApplicationRecord
include EmailValidatable
# Relations
has_many :invitations, dependent: :destroy
validates_uniqueness_of :cn
validates_uniqueness_of :email
validates_length_of :cn, :minimum => 3
validates_uniqueness_of :email
validates :email, email: true
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable

View File

@@ -1,4 +1,6 @@
class ApplicationService
# This enables executing a service's `#call` method directly via
# `MyService.call(args)`, without creating a class instance it first.
def self.call(*args, &block)
new(*args, &block).call
end

View File

@@ -1,16 +1,38 @@
class CreateAccount < ApplicationService
def initialize(args)
@username = args[:username]
@email = args[:email]
@password = args[:password]
@username = args[:username]
@domain = args[:ou] || "kosmos.org"
@email = args[:email]
@password = args[:password]
@invitation = args[:invitation]
end
def call
user = create_user_in_database
add_ldap_document
if @invitation.present?
update_invitation(user.id)
exchange_xmpp_contacts
end
end
private
def create_user_in_database
User.create!(
cn: @username,
ou: @domain,
email: @email,
password: @password,
password_confirmation: @password
)
end
def update_invitation(user_id)
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
end
def add_ldap_document
dn = "cn=#{@username},ou=kosmos.org,cn=users,dc=kosmos,dc=org"
attr = {
@@ -39,4 +61,23 @@ class CreateAccount < ApplicationService
def ldap_config
ldap_config ||= YAML.load(ERB.new(File.read("#{Rails.root}/config/ldap.yml")).result)[Rails.env]
end
def exchange_xmpp_contacts
#TODO enable in development when we have easy setup of ejabberd etc.
return if Rails.env.development?
ejabberd = EjabberdApiClient.new
inviter = @invitation.user
ejabberd.add_rosteritem({
"localuser": @username, "localhost": @domain,
"user": inviter.cn, "host": inviter.ou,
"nick": inviter.cn, "group": "Friends", "subs": "both"
})
ejabberd.add_rosteritem({
"localuser": inviter.cn, "localhost": inviter.ou,
"user": @username, "host": @domain,
"nick": @username, "group": "Friends", "subs": "both"
})
end
end

View File

@@ -0,0 +1,20 @@
class EjabberdApiClient
def initialize
@base_url = ENV["EJABBERD_API_URL"]
end
def post(endpoint, payload)
res = Faraday.post("#{@base_url}/#{endpoint}", payload.to_json,
"Content-Type" => "application/json")
if res.status != 200
Rails.logger.error "[ejabberd] API request failed:"
Rails.logger.error res.body
#TODO add some kind of exception tracking/notifications
end
end
def add_rosteritem(payload)
post "add_rosteritem", payload
end
end

View File

@@ -6,7 +6,7 @@
</p>
<div class="grid services">
<div class="grid-item chat">
<h3><%= link_to "Chat", "https://wiki.kosmos.org/Services:XMPP" %></h3>
<h3><%= link_to "Chat", "https://wiki.kosmos.org/Services:Chat" %></h3>
<p>
Chat rooms and instant messaging (XMPP/Jabber)
</p>

View File

@@ -1,23 +1,46 @@
<section>
<h2>Invitations</h2>
<% if @invitations_unused.any? %>
<table>
<thead>
<tr>
<th>URL</th>
</tr>
</thead>
<tbody>
<% @invitations_unused.each do |invitation| %>
<tr>
<td><%= invitation_url(invitation.token) %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>
You do not have any invitations to give away yet. All good
things come in time.
</p>
<% end %>
</section>
<% if @invitations_used.any? %>
<h3>Accepted Invitations</h3>
<table>
<thead>
<tr>
<th>Token</th>
<th>Created at</th>
<th colspan="3"></th>
<th>URL</th>
<th>Used at</th>
<th>Invited user</th>
</tr>
</thead>
<tbody>
<% @invitations.each do |invitation| %>
<% @invitations_used.each do |invitation| %>
<tr>
<td><%= invitation.token %></td>
<td><%= invitation.created_at %></td>
<td><%= link_to 'Delete', invitation, method: :delete, data: { confirm: 'Are you sure?' } %></td>
<td><%= invitation_url(invitation.token) %></td>
<td><%= invitation.used_at %></td>
<td><%= User.find(invitation.invited_user_id).address %></td>
</tr>
<% end %>
</tbody>
</table>
</section>
<% end %>

View File

@@ -48,8 +48,14 @@
<p class="error-msg">Password <%= @validation_error %></p>
<% end %>
</div>
<p class="accept-terms">
<small>
By clicking the button below, you accept our future Terms of Service
and Privacy Policy. Don't worry, they will be excellent!
</small>
</p>
<div class="actions">
<p><%= f.submit "Continue" %></p>
<p><%= f.submit "Create account" %></p>
</div>
<% end %>
<% end %>

View File

@@ -43,4 +43,10 @@ Rails.application.configure do
# Raises error for missing translations.
# config.action_view.raise_on_missing_translations = true
config.action_mailer.default_url_options = {
host: "accounts.kosmos.org",
protocol: "https",
from: "accounts@kosmos.org"
}
end

View File

@@ -8,5 +8,7 @@ class CreateInvitations < ActiveRecord::Migration[6.0]
t.timestamps
end
add_index :invitations, :user_id
add_index :invitations, :invited_user_id
end
end

View File

@@ -1,6 +1,5 @@
FactoryBot.define do
factory :invitation do
id { 1 }
token { "abcdef123456" }
user
end

View File

@@ -3,37 +3,105 @@ require "rails_helper"
RSpec.describe "Signup", type: :feature do
let(:user) { create :user }
before do
@unused_invitation = Invitation.create(user: user)
@used_invitation = Invitation.create(user: user)
@used_invitation.update_attribute :used_at, DateTime.now - 1.day
end
describe "Invitation handling" do
before do
@unused_invitation = Invitation.create(user: user)
@used_invitation = Invitation.create(user: user)
@used_invitation.update_attribute :used_at, DateTime.now - 1.day
end
scenario "Follow link for non-existing invitation" do
visit invitation_url(id: "123")
scenario "Follow link for non-existing invitation" do
visit invitation_url(id: "123")
within ".flash-msg.alert" do
expect(page).to have_content("doesn't exist")
within ".flash-msg.alert" do
expect(page).to have_content("doesn't exist")
end
end
scenario "Follow link for used invitation" do
visit invitation_url(id: @used_invitation.token)
within ".flash-msg.alert" do
expect(page).to have_content("has already been used")
end
end
scenario "Follow link for unused invitation" do
visit invitation_url(id: @unused_invitation.token)
expect(current_url).to eq(signup_url)
expect(page).to have_content("Welcome")
end
end
scenario "Follow link for used invitation" do
visit invitation_url(id: @used_invitation.token)
within ".flash-msg.alert" do
expect(page).to have_content("has already been used")
describe "Signup steps" do
before do
@invitation = Invitation.create(user: user)
visit invitation_url(id: @invitation.token)
click_link "Get started"
end
end
scenario "Follow link for unused invitation" do
visit invitation_url(id: @unused_invitation.token)
scenario "Successful signup (happy path galore)" do
expect(page).to have_content("Choose a username")
expect(current_url).to eq(signup_url)
expect(page).to have_content("Welcome")
end
fill_in "user_cn", with: "tony"
click_button "Continue"
expect(page).to have_content("What's your email?")
scenario "Successful signup" do
visit invitation_url(id: @unused_invitation.token)
click_link "Get started"
fill_in "user_email", with: "tony@example.com"
click_button "Continue"
expect(page).to have_content("Choose a password")
expect(CreateAccount).to receive(:call)
.with(
username: "tony", domain: "kosmos.org",
email: "tony@example.com", password: "a-valid-password",
invitation: Invitation.last
).and_return(true)
fill_in "user_password", with: "a-valid-password"
click_button "Create account"
within ".flash-msg.notice" do
expect(page).to have_content("confirm your address")
end
expect(page).to have_content("close this window or tab now")
end
scenario "Validation errors" do
fill_in "user_cn", with: "t"
click_button "Continue"
expect(page).to have_content("Username is too short")
fill_in "user_cn", with: "jimmy"
click_button "Continue"
expect(page).to have_content("Username has already been taken")
fill_in "user_cn", with: "tony"
click_button "Continue"
fill_in "user_email", with: "tony@"
click_button "Continue"
expect(page).to have_content("Email is not a valid address")
fill_in "user_email", with: ""
click_button "Continue"
expect(page).to have_content("Email can't be blank")
fill_in "user_email", with: "tony@example.com"
click_button "Continue"
fill_in "user_password", with: "123456"
click_button "Create account"
expect(page).to have_content("Password is too short")
expect(CreateAccount).to receive(:call)
.with(
username: "tony", domain: "kosmos.org",
email: "tony@example.com", password: "a-valid-password",
invitation: Invitation.last
).and_return(true)
fill_in "user_password", with: "a-valid-password"
click_button "Create account"
within ".flash-msg.notice" do
expect(page).to have_content("confirm your address")
end
end
end
end

View File

@@ -19,4 +19,26 @@ RSpec.describe Invitation, type: :model do
expect(token).not_to eq(invitation_2.token)
end
end
describe "scopes" do
before do
@unused_invitation = create :invitation, user: user
@used_invitation = create :invitation, user: user, used_at: DateTime.now
@used_invitation_2 = create :invitation, user: user, used_at: DateTime.now
end
describe "#unused" do
it "returns unused invitations" do
expect(Invitation.unused.count).to eq(1)
expect(Invitation.unused.first).to eq(@unused_invitation)
end
end
describe "#used" do
it "returns used invitations" do
expect(Invitation.used.count).to eq(2)
expect(Invitation.used.first).to eq(@used_invitation)
end
end
end
end

View File

@@ -1,4 +1,6 @@
require 'rails_helper'
require 'webmock/rspec'
require 'json'
RSpec.describe CreateAccount, type: :model do
let(:ldap_client_mock) { instance_double(Net::LDAP) }
@@ -7,6 +9,44 @@ RSpec.describe CreateAccount, type: :model do
allow(service).to receive(:ldap_client).and_return(ldap_client_mock)
end
describe "#create_user_in_database" do
let(:service) { CreateAccount.new(
username: 'isaacnewton',
email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn'
)}
it "creates a new user record in the akkounts database" do
expect(User.count).to eq(0)
service.send(:create_user_in_database)
expect(User.count).to eq(1)
expect(User.last.cn).to eq("isaacnewton")
expect(User.last.email).to eq("isaacnewton@example.com")
end
end
describe "#update_invitation" do
let(:invitation) { create :invitation }
let(:service) { CreateAccount.new(
username: 'isaacnewton',
email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn',
invitation: invitation
)}
before(:each) do
service.send(:update_invitation, 23)
end
it "marks the invitation as used" do
expect(invitation.used_at).not_to be_nil
end
it "saves the invited user's ID" do
expect(invitation.invited_user_id).to eq(23)
end
end
describe "#add_ldap_document" do
let(:service) { CreateAccount.new(
username: 'halfinney',
@@ -30,4 +70,29 @@ RSpec.describe CreateAccount, type: :model do
service.send(:add_ldap_document)
end
end
describe "#exchange_xmpp_contacts" do
let(:inviter) { create :user, cn: "willherschel", ou: "kosmos.org" }
let(:invitation) { create :invitation, user: inviter }
let(:service) { CreateAccount.new(
username: 'isaacnewton',
email: 'isaacnewton@example.com',
password: 'bright-ideas-in-autumn',
invitation: invitation
)}
before do
stub_request(:post, "http://xmpp.example.com/api/add_rosteritem")
.to_return(status: 200, body: "", headers: {})
end
it "posts add_rosteritem commands to the ejabberd API" do
service.send(:exchange_xmpp_contacts)
expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem")
.with { |req| req.body == '{"localuser":"isaacnewton","localhost":"kosmos.org","user":"willherschel","host":"kosmos.org","nick":"willherschel","group":"Friends","subs":"both"}' }
expect(WebMock).to have_requested(:post, "http://xmpp.example.com/api/add_rosteritem")
.with { |req| req.body == '{"localuser":"willherschel","localhost":"kosmos.org","user":"isaacnewton","host":"kosmos.org","nick":"isaacnewton","group":"Friends","subs":"both"}' }
end
end
end