diff --git a/.drone.yml b/.drone.yml
index d38f769..8d07bcf 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -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
diff --git a/README.md b/README.md
index 57c819c..0711052 100644
--- a/README.md
+++ b/README.md
@@ -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 invite
* [ ] Sign up for a new account by donating upfront
* [ ] Sign up for a new account via proving contributions (via cryptographic signature)
* [ ] ...
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
new file mode 100644
index 0000000..e8afb30
--- /dev/null
+++ b/app/assets/stylesheets/forms.scss
@@ -0,0 +1,35 @@
+form {
+ .field_with_errors {
+ display: inline-block;
+ }
+}
+
+.layout-signup {
+ label {
+ display: none;
+ }
+
+ input[type=text], input[type=email], input[type=password] {
+ font-size: 1.25rem;
+ padding: 0.5rem 1rem;
+ }
+
+ span.at-sign, span.domain {
+ font-size: 1.25rem;
+ }
+
+ .error-msg {
+ color: #bc0101;
+ }
+
+ .actions {
+ margin-top: 2rem;
+ }
+
+ .accept-terms {
+ margin-top: 2rem;
+ font-size: 0.85rem;
+ line-height: 1.5em;
+ color: #888;
+ }
+}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index dd4cf1c..c672886 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -9,6 +9,12 @@ class ApplicationController < ActionController::Base
end
end
+ def require_user_signed_out
+ if user_signed_in?
+ redirect_to root_path and return
+ end
+ end
+
def authorize_admin
http_status :forbidden unless current_user.is_admin?
end
diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb
new file mode 100644
index 0000000..c0affd8
--- /dev/null
+++ b/app/controllers/invitations_controller.rb
@@ -0,0 +1,49 @@
+class InvitationsController < ApplicationController
+ before_action :require_user_signed_in, except: ["show"]
+ before_action :require_user_signed_out, only: ["show"]
+
+ layout "signup", only: ["show"]
+
+ # GET /invitations
+ def index
+ @invitations_unused = current_user.invitations.unused
+ @invitations_used = current_user.invitations.used
+ end
+
+ # GET /invitations/a-random-invitation-token
+ def show
+ token = session[:invitation_token] = params[:id]
+
+ if Invitation.where(token: token, used_at: nil).exists?
+ redirect_to signup_path and return
+ else
+ flash.now[:alert] = "This invitation either doesn't exist or has already been used."
+ http_status :unauthorized
+ end
+ end
+
+ # POST /invitations
+ def create
+ @invitation = Invitation.new(user: current_user)
+
+ respond_to do |format|
+ if @invitation.save
+ format.html { redirect_to @invitation, notice: 'Invitation was successfully created.' }
+ format.json { render :show, status: :created, location: @invitation }
+ else
+ format.html { render :new }
+ format.json { render json: @invitation.errors, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ # DELETE /invitations/1
+ def destroy
+ @invitation = current_user.invitations.find(params[:id])
+ @invitation.destroy
+ respond_to do |format|
+ format.html { redirect_to invitations_url }
+ format.json { head :no_content }
+ end
+ end
+end
diff --git a/app/controllers/signup_controller.rb b/app/controllers/signup_controller.rb
new file mode 100644
index 0000000..b332bbe
--- /dev/null
+++ b/app/controllers/signup_controller.rb
@@ -0,0 +1,109 @@
+class SignupController < ApplicationController
+ before_action :require_user_signed_out
+ before_action :require_invitation
+ before_action :set_invitation
+ before_action :set_new_user, only: ["steps", "validate"]
+
+ layout "signup"
+
+ def index
+ @invited_by_name = @invitation.user.address
+ end
+
+ def steps
+ @step = params[:step].to_i
+ http_status :not_found unless [1,2,3].include?(@step)
+ @validation_error = session[:validation_error]
+ end
+
+ def validate
+ session[:validation_error] = nil
+
+ case user_params.keys.first
+ when "cn"
+ @user.cn = user_params[:cn]
+ @user.valid?
+ session[:new_user] = @user
+
+ if @user.errors[:cn].present?
+ session[:validation_error] = @user.errors[:cn].first # Store user including validation errors
+ redirect_to signup_steps_path(1) and return
+ else
+ redirect_to signup_steps_path(2) and return
+ end
+ when "email"
+ @user.email = user_params[:email]
+ @user.valid?
+ session[:new_user] = @user
+
+ if @user.errors[:email].present?
+ session[:validation_error] = @user.errors[:email].first # Store user including validation errors
+ redirect_to signup_steps_path(2) and return
+ else
+ redirect_to signup_steps_path(3) and return
+ end
+ when "password"
+ @user.password = user_params[:password]
+ @user.password_confirmation = user_params[:password]
+ @user.valid?
+ session[:new_user] = @user
+
+ if @user.errors[:password].present?
+ session[:validation_error] = @user.errors[:password].first # Store user including validation errors
+ redirect_to signup_steps_path(3) and return
+ else
+ complete_signup
+ msg = "Almost done! We have sent you an email to confirm your address."
+ redirect_to(check_your_email_path, notice: msg) and return
+ end
+ end
+ end
+
+ private
+
+ def user_params
+ params.require(:user).permit(:cn, :email, :password)
+ end
+
+ def require_invitation
+ if session[:invitation_token].blank?
+ flash.now[:alert] = "You need an invitation to sign up for an account."
+ http_status :unauthorized
+ elsif !valid_invitation?(session[:invitation_token])
+ flash.now[:alert] = "This invitation either doesn't exist or has already been used."
+ http_status :unauthorized
+ end
+
+ @invitation = Invitation.find_by(token: session[:invitation_token])
+ end
+
+ def valid_invitation?(token)
+ Invitation.where(token: session[:invitation_token], used_at: nil).exists?
+ end
+
+ def set_invitation
+ @invitation = Invitation.find_by(token: session[:invitation_token])
+ end
+
+ def set_new_user
+ if session[:new_user].present?
+ @user = User.new(session[:new_user])
+ else
+ @user = User.new(ou: "kosmos.org")
+ end
+ end
+
+ def complete_signup
+ @user.save!
+ session[:new_user] = nil
+ session[:validation_error] = nil
+
+ CreateAccount.call(
+ username: @user.cn,
+ email: @user.email,
+ password: @user.password
+ )
+
+ @invitation.update! invited_user_id: @user.id, used_at: DateTime.now
+ end
+end
diff --git a/app/helpers/invitations_helper.rb b/app/helpers/invitations_helper.rb
new file mode 100644
index 0000000..1483b9e
--- /dev/null
+++ b/app/helpers/invitations_helper.rb
@@ -0,0 +1,2 @@
+module InvitationsHelper
+end
diff --git a/app/helpers/signup_helper.rb b/app/helpers/signup_helper.rb
new file mode 100644
index 0000000..f954dda
--- /dev/null
+++ b/app/helpers/signup_helper.rb
@@ -0,0 +1,2 @@
+module SignupHelper
+end
diff --git a/app/models/concerns/email_validatable.rb b/app/models/concerns/email_validatable.rb
new file mode 100644
index 0000000..6a83881
--- /dev/null
+++ b/app/models/concerns/email_validatable.rb
@@ -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
diff --git a/app/models/invitation.rb b/app/models/invitation.rb
new file mode 100644
index 0000000..e4910a5
--- /dev/null
+++ b/app/models/invitation.rb
@@ -0,0 +1,20 @@
+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
+ self.token = SecureRandom.hex(8)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4d60b98..6987a0a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,4 +1,14 @@
class User < ApplicationRecord
+ include EmailValidatable
+
+ # Relations
+ has_many :invitations, dependent: :destroy
+
+ validates_uniqueness_of :cn
+ 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
devise :ldap_authenticatable,
@@ -33,4 +43,13 @@ class User < ApplicationRecord
false
end
end
+
+ def address
+ "#{self.cn}@#{self.ou}"
+ end
+
+ def valid_attribute?(attribute_name)
+ self.valid?
+ self.errors[attribute_name].blank?
+ end
end
diff --git a/app/services/application_service.rb b/app/services/application_service.rb
new file mode 100644
index 0000000..401fe02
--- /dev/null
+++ b/app/services/application_service.rb
@@ -0,0 +1,7 @@
+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
+end
diff --git a/app/services/create_account.rb b/app/services/create_account.rb
new file mode 100644
index 0000000..474427a
--- /dev/null
+++ b/app/services/create_account.rb
@@ -0,0 +1,42 @@
+class CreateAccount < ApplicationService
+ def initialize(args)
+ @username = args[:username]
+ @email = args[:email]
+ @password = args[:password]
+ end
+
+ def call
+ add_ldap_document
+ end
+
+ private
+
+ def add_ldap_document
+ dn = "cn=#{@username},ou=kosmos.org,cn=users,dc=kosmos,dc=org"
+ attr = {
+ objectclass: ["top", "account", "person", "extensibleObject"],
+ cn: @username,
+ sn: @username,
+ uid: @username,
+ mail: @email,
+ userPassword: Devise.ldap_auth_password_builder.call(@password)
+ }
+
+ ldap_client.add(dn: dn, attributes: attr)
+ end
+
+ def ldap_client
+ ldap_client ||= Net::LDAP.new host: ldap_config['host'],
+ port: ldap_config['port'],
+ encryption: ldap_config['ssl'],
+ auth: {
+ method: :simple,
+ username: ldap_config['admin_user'],
+ password: ldap_config['admin_password']
+ }
+ end
+
+ def ldap_config
+ ldap_config ||= YAML.load(ERB.new(File.read("#{Rails.root}/config/ldap.yml")).result)[Rails.env]
+ end
+end
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb
index 3b4d234..534a39e 100644
--- a/app/views/dashboard/index.html.erb
+++ b/app/views/dashboard/index.html.erb
@@ -11,18 +11,6 @@
Chat rooms and instant messaging (XMPP/Jabber)
-
-
<%= link_to "Gitea", "https://gitea.kosmos.org" %>
-
- Code hosting and collaboration for software projects
-
-
-
-
<%= link_to "Drone CI", "https://drone.kosmos.org" %>
-
- Continuous integration for software projects, tied to our Gitea
-
-
<%= link_to "Wiki", "https://wiki.kosmos.org" %>
@@ -35,6 +23,18 @@
Kosmos community forums and user support/help site
+
+
<%= link_to "Gitea", "https://gitea.kosmos.org" %>
+
+ Code hosting and collaboration for software projects
+
+
+
+
<%= link_to "Drone CI", "https://drone.kosmos.org" %>
+
+ Continuous integration for software projects, tied to our Gitea
+
+
diff --git a/app/views/invitations/index.html.erb b/app/views/invitations/index.html.erb
new file mode 100644
index 0000000..5b461ce
--- /dev/null
+++ b/app/views/invitations/index.html.erb
@@ -0,0 +1,46 @@
+
+ Invitations
+ <% if @invitations_unused.any? %>
+
+
+
+ URL |
+
+
+
+ <% @invitations_unused.each do |invitation| %>
+
+ <%= invitation_url(invitation.token) %> |
+
+ <% end %>
+
+
+ <% else %>
+
+ You do not have any invitations to give away yet. All good
+ things come in time.
+
+ <% end %>
+
+
+<% if @invitations_used.any? %>
+ Accepted Invitations
+
+
+
+ URL |
+ Used at |
+ Invited user |
+
+
+
+ <% @invitations_used.each do |invitation| %>
+
+ <%= invitation_url(invitation.token) %> |
+ <%= invitation.used_at %> |
+ <%= User.find(invitation.invited_user_id).address %> |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/layouts/signup.html.erb b/app/views/layouts/signup.html.erb
new file mode 100644
index 0000000..1a49165
--- /dev/null
+++ b/app/views/layouts/signup.html.erb
@@ -0,0 +1,41 @@
+
+
+
+ Sign up | Kosmos Accounts
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+
+
+ <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
+ <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+
+
+
+
+
+
+ Kosmos
+ Sign Up
+
+
+ <% if user_signed_in? %>
+
+ Signed in as <%= current_user.cn %>@kosmos.org.
+ <%= link_to "Log out", destroy_user_session_path, method: :delete %>
+
+ <% end %>
+
+
+ <% flash.each do |type, msg| %>
+
+ <% end %>
+
+
+ <%= yield %>
+
+
+
+
diff --git a/app/views/shared/status_forbidden.html.erb b/app/views/shared/status_forbidden.html.erb
index a00cb2d..cff9a63 100644
--- a/app/views/shared/status_forbidden.html.erb
+++ b/app/views/shared/status_forbidden.html.erb
@@ -1,2 +1,2 @@
Access forbidden
-Not with those shoes, buddy.
+Sorry, you're not allowed to access this page.
diff --git a/app/views/shared/status_not_found.html.erb b/app/views/shared/status_not_found.html.erb
new file mode 100644
index 0000000..c31810c
--- /dev/null
+++ b/app/views/shared/status_not_found.html.erb
@@ -0,0 +1,2 @@
+Not found
+Sorry, this page does not exist.
diff --git a/app/views/shared/status_unauthorized.html.erb b/app/views/shared/status_unauthorized.html.erb
new file mode 100644
index 0000000..e69de29
diff --git a/app/views/signup/index.html.erb b/app/views/signup/index.html.erb
new file mode 100644
index 0000000..f3d8b80
--- /dev/null
+++ b/app/views/signup/index.html.erb
@@ -0,0 +1,12 @@
+Welcome
+
+ Hey there! You were invited to sign up for a Kosmos account by
+ <%= @invited_by_name %>.
+
+
+ This invitation can only be used once, and sign-up is currently only possible
+ by invitation. Seems like you have good friends!
+
+
+ <%= link_to "Get started", signup_steps_path(1), class: "next-step" %>
+
diff --git a/app/views/signup/steps.html.erb b/app/views/signup/steps.html.erb
new file mode 100644
index 0000000..3c527f4
--- /dev/null
+++ b/app/views/signup/steps.html.erb
@@ -0,0 +1,61 @@
+<% case @step %>
+<% when 1 %>
+ Choose a username
+ <%= form_for @user, :url => signup_validate_url do |f| %>
+
+
+ <%= f.label :cn, 'Username' %>
+ <%= f.text_field :cn, autofocus: true, autocomplete: "username" %>
+ @
+ kosmos.org
+
+ <% if @validation_error.present? %>
+
Username <%= @validation_error %>
+ <% end %>
+
+
+
<%= f.submit "Continue" %>
+
+ <% end %>
+
+<% when 2 %>
+ What's your email?
+ <%= form_for @user, :url => signup_validate_url do |f| %>
+
+
+ <%= f.label :email, 'Email address' %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
+
+ <% if @validation_error.present? %>
+
Email <%= @validation_error %>
+ <% end %>
+
+
+
<%= f.submit "Continue" %>
+
+ <% end %>
+
+<% when 3 %>
+ Choose a password
+
+ <%= form_for @user, :url => signup_validate_url do |f| %>
+
+
+ <%= f.label :password, 'Password' %>
+ <%= f.password_field :password, autofocus: true %>
+
+ <% if @validation_error.present? %>
+
Password <%= @validation_error %>
+ <% end %>
+
+
+
+ By clicking the button below, you accept our future Terms of Service
+ and Privacy Policy. Don't worry, they will be excellent!
+
+
+
+
<%= f.submit "Create account" %>
+
+ <% end %>
+<% end %>
diff --git a/config/application.rb b/config/application.rb
index f171fca..127cf2f 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -33,10 +33,11 @@ module Akkounts
config.generators.system_tests = nil
config.generators do |g|
- g.orm :active_record
- g.template_engine :erb
- g.test_framework :rspec, fixture: true
- g.stylesheets false
+ g.orm :active_record
+ g.template_engine :erb
+ g.test_framework :rspec, fixture: true
+ g.fixture_replacement :factory_bot, suffix_factory: 'factory', dir: 'spec/factories'
+ g.stylesheets false
end
end
end
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 3375f1c..ac775c3 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -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
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
index d3c0c52..9003184 100644
--- a/config/locales/devise.en.yml
+++ b/config/locales/devise.en.yml
@@ -3,7 +3,7 @@
en:
devise:
confirmations:
- confirmed: "Your email address has been confirmed. You can now log in below."
+ confirmed: "Thanks for confirming your email address! Your account has been activated."
send_instructions: "You will receive an email with instructions for how to confirm your email address in a moment."
send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
failure:
@@ -15,7 +15,7 @@ en:
not_found_in_database: "Invalid %{authentication_keys} or password."
timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing."
- unconfirmed: "You have to confirm your email address before continuing."
+ unconfirmed: "Please confirm your email address before continuing."
mailer:
confirmation_instructions:
subject: "Confirmation instructions"
diff --git a/config/routes.rb b/config/routes.rb
index 72a33b2..bdefef0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,11 +1,17 @@
Rails.application.routes.draw do
devise_for :users
+ get 'welcome', to: 'welcome#index'
+ get 'check_your_email', to: 'welcome#check_your_email'
+
+ get 'signup', to: 'signup#index'
+ match 'signup/:step', to: 'signup#steps', as: :signup_steps, via: [:get, :post]
+ post 'signup_validate', to: 'signup#validate'
+
get 'settings', to: 'settings#index'
post 'settings_reset_password', to: 'settings#reset_password'
- get 'welcome', to: 'welcome#index'
- get 'check_your_email', to: 'welcome#check_your_email'
+ resources :invitations, only: ['index', 'show', 'create', 'destroy']
namespace :admin do
root to: 'dashboard#index'
diff --git a/db/migrate/20201130132533_create_invitations.rb b/db/migrate/20201130132533_create_invitations.rb
new file mode 100644
index 0000000..0b53910
--- /dev/null
+++ b/db/migrate/20201130132533_create_invitations.rb
@@ -0,0 +1,14 @@
+class CreateInvitations < ActiveRecord::Migration[6.0]
+ def change
+ create_table :invitations do |t|
+ t.string :token
+ t.integer :user_id
+ t.integer :invited_user_id
+ t.datetime :used_at
+
+ t.timestamps
+ end
+ add_index :invitations, :user_id
+ add_index :invitations, :invited_user_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d2d374e..3d99780 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,16 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_11_09_090739) do
+ActiveRecord::Schema.define(version: 2020_11_30_132533) do
+
+ create_table "invitations", force: :cascade do |t|
+ t.string "token"
+ t.integer "user_id"
+ t.integer "invited_user_id"
+ t.datetime "used_at"
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ end
create_table "users", force: :cascade do |t|
t.string "cn"
diff --git a/lib/tasks/invitations.rake b/lib/tasks/invitations.rake
new file mode 100644
index 0000000..1accd05
--- /dev/null
+++ b/lib/tasks/invitations.rake
@@ -0,0 +1,13 @@
+namespace :invitations do
+ desc "Generate invitations for all users"
+ task :generate_for_all_users, [:amount_per_user] => :environment do |t, args|
+ count = 0
+ User.all.each do |user|
+ args[:amount_per_user].to_i.times do
+ user.invitations << Invitation.create(user: user)
+ count += 1
+ end
+ end
+ puts "Created #{count} new invitations"
+ end
+end
diff --git a/spec/factories/invitations.rb b/spec/factories/invitations.rb
new file mode 100644
index 0000000..5ca1ad3
--- /dev/null
+++ b/spec/factories/invitations.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :invitation do
+ token { "abcdef123456" }
+ user
+ end
+end
diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb
new file mode 100644
index 0000000..09ebcc5
--- /dev/null
+++ b/spec/features/signup_spec.rb
@@ -0,0 +1,103 @@
+require "rails_helper"
+
+RSpec.describe "Signup", type: :feature do
+ let(:user) { create :user }
+
+ 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")
+
+ 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
+
+ describe "Signup steps" do
+ before do
+ @invitation = Invitation.create(user: user)
+ visit invitation_url(id: @invitation.token)
+ click_link "Get started"
+ end
+
+ scenario "Successful signup (happy path galore)" do
+ expect(page).to have_content("Choose a username")
+
+ fill_in "user_cn", with: "tony"
+ click_button "Continue"
+ expect(page).to have_content("What's your email?")
+
+ 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", email: "tony@example.com", password: "a-valid-password")
+ .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")
+ expect(User.last.confirmed_at).to be_nil
+ 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", email: "tony@example.com", password: "a-valid-password")
+ .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(User.last.cn).to eq("tony")
+ end
+ end
+end
diff --git a/spec/fixtures/users.yml b/spec/fixtures/users.yml
deleted file mode 100644
index 1e8c596..0000000
--- a/spec/fixtures/users.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
-one:
- uid: MyString
-
-two:
- uid: MyString
diff --git a/spec/helpers/invitations_helper_spec.rb b/spec/helpers/invitations_helper_spec.rb
new file mode 100644
index 0000000..4ca2783
--- /dev/null
+++ b/spec/helpers/invitations_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+# Specs in this file have access to a helper object that includes
+# the InvitationsHelper. For example:
+#
+# describe InvitationsHelper do
+# describe "string concat" do
+# it "concats two strings with spaces" do
+# expect(helper.concat_strings("this","that")).to eq("this that")
+# end
+# end
+# end
+RSpec.describe InvitationsHelper, type: :helper do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/helpers/signup_helper_spec.rb b/spec/helpers/signup_helper_spec.rb
new file mode 100644
index 0000000..0ed5adc
--- /dev/null
+++ b/spec/helpers/signup_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+# Specs in this file have access to a helper object that includes
+# the SignupHelper. For example:
+#
+# describe SignupHelper do
+# describe "string concat" do
+# it "concats two strings with spaces" do
+# expect(helper.concat_strings("this","that")).to eq("this that")
+# end
+# end
+# end
+RSpec.describe SignupHelper, type: :helper do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/models/invitation_spec.rb b/spec/models/invitation_spec.rb
new file mode 100644
index 0000000..3ec7237
--- /dev/null
+++ b/spec/models/invitation_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+
+RSpec.describe Invitation, type: :model do
+ let(:user) { build :user }
+
+ describe "tokens" do
+ it "requires a user" do
+ expect { Invitation.create! }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+
+ it "generates a random token when created" do
+ invitation = Invitation.new(user: user)
+ invitation.save!
+ token = invitation.token
+ expect(token).to be_a(String)
+ expect(token.length).to eq(16)
+
+ invitation_2 = Invitation.create(user: user)
+ 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
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 3bc4941..ed135d9 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -20,4 +20,12 @@ RSpec.describe User, type: :model do
expect(user.is_admin?).to be false
end
end
+
+ describe "#address" do
+ let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
+
+ it "returns the user address" do
+ expect(user.address).to eq("jimmy@kosmos.org")
+ end
+ end
end
diff --git a/spec/requests/signup_request_spec.rb b/spec/requests/signup_request_spec.rb
new file mode 100644
index 0000000..a782f48
--- /dev/null
+++ b/spec/requests/signup_request_spec.rb
@@ -0,0 +1,14 @@
+require 'rails_helper'
+
+RSpec.describe "Signups", type: :request do
+
+ describe "GET /index" do
+ context "without invitation" do
+ it "returns http unauthorized" do
+ get "/signup"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+end
diff --git a/spec/services/create_account_spec.rb b/spec/services/create_account_spec.rb
new file mode 100644
index 0000000..dad16fb
--- /dev/null
+++ b/spec/services/create_account_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe CreateAccount, type: :model do
+ let(:ldap_client_mock) { instance_double(Net::LDAP) }
+
+ before do
+ allow(service).to receive(:ldap_client).and_return(ldap_client_mock)
+ end
+
+ describe "#add_ldap_document" do
+ let(:service) { CreateAccount.new(
+ username: 'halfinney',
+ email: 'halfinney@example.com',
+ password: 'remember-remember-the-5th-of-november'
+ )}
+
+ it "creates a new document with the correct attributes" do
+ expect(ldap_client_mock).to receive(:add).with(
+ dn: "cn=halfinney,ou=kosmos.org,cn=users,dc=kosmos,dc=org",
+ attributes: {
+ objectclass: ["top", "account", "person", "extensibleObject"],
+ cn: "halfinney",
+ sn: "halfinney",
+ uid: "halfinney",
+ mail: "halfinney@example.com",
+ userPassword: /^{SSHA512}.{171}=/
+ }
+ )
+
+ service.send(:add_ldap_document)
+ end
+ end
+end