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? %> + + + + + + + + <% @invitations_unused.each do |invitation| %> + + + + <% end %> + +
URL
<%= invitation_url(invitation.token) %>
+ <% else %> +

+ You do not have any invitations to give away yet. All good + things come in time. +

+ <% end %> +
+ +<% if @invitations_used.any? %> +

Accepted Invitations

+ + + + + + + + + + <% @invitations_used.each do |invitation| %> + + + + + + <% end %> + +
URLUsed atInvited user
<%= invitation_url(invitation.token) %><%= invitation.used_at %><%= User.find(invitation.invited_user_id).address %>
+<% 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| %> +
+

<%= 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