diff options
103 files changed, 1732 insertions, 360 deletions
| @@ -17,7 +17,6 @@  /pkg  /*/pkg  /log -Gemfile.lock  */Gemfile.lock  test/dummy/log/*  test/dummy/tmp/* @@ -1,11 +1,14 @@ -# Customization # +Customization +============================== -Leap Web is based on Engines. All things in `app` will overwrite the default behaviour. You can either create a new rails app and include the leap_web gem or clone the leap web repository and add your customizations to the `app` directory. +Customization directory +--------------------------------------- -## CSS Customization ## +See config/customization/README.md -We use scss. It's a superset of css3. Add your customizations to `app/assets/stylesheets`. +Engines +--------------------- -## Disabling an Engine ## +Leap Web is based on Engines. All things in `app` will overwrite the default behaviour. You can either create a new rails app and include the leap_web gem or clone the leap web repository and add your customizations to the `app` directory.  If you have no use for one of the engines you can remove it from the Gemfile. Not however that your app might still need to provide some functionality for the other engines to work. For example the users engine provides `current_user` and other methods. @@ -29,4 +29,4 @@ group :test do  end  # unreleased so far ... but leap_web_certs need it -gem 'certificate_authority', :git => 'git://github.com/cchandler/certificate_authority.git' +gem 'certificate_authority', :git => 'https://github.com/cchandler/certificate_authority.git' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..918fdba --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,287 @@ +GIT +  remote: https://github.com/cchandler/certificate_authority.git +  revision: 58161e4552cc1aeca846da3e25ed66721354ee11 +  specs: +    certificate_authority (0.2.0) +      activemodel (>= 3.0.6) + +PATH +  remote: billing +  specs: +    leap_web_billing (0.2.8) +      braintree +      leap_web_core (= 0.2.8) + +PATH +  remote: certs +  specs: +    leap_web_certs (0.2.8) +      certificate_authority (>= 0.2.0) +      leap_web_core (= 0.2.8) + +PATH +  remote: core +  specs: +    leap_web_core (0.2.8) +      couchrest (~> 1.1.3) +      couchrest_model (~> 2.0.0) +      couchrest_session_store (~> 0.2.4) +      json +      rails (~> 3.2.11) + +PATH +  remote: help +  specs: +    leap_web_help (0.2.8) +      leap_web_core (= 0.2.8) + +PATH +  remote: users +  specs: +    leap_web_users (0.2.8) +      leap_web_core (= 0.2.8) +      rails_warden +      ruby-srp (~> 0.2.1) + +GEM +  remote: https://rubygems.org/ +  specs: +    SyslogLogger (2.0) +    actionmailer (3.2.15) +      actionpack (= 3.2.15) +      mail (~> 2.5.4) +    actionpack (3.2.15) +      activemodel (= 3.2.15) +      activesupport (= 3.2.15) +      builder (~> 3.0.0) +      erubis (~> 2.7.0) +      journey (~> 1.0.4) +      rack (~> 1.4.5) +      rack-cache (~> 1.2) +      rack-test (~> 0.6.1) +      sprockets (~> 2.2.1) +    activemodel (3.2.15) +      activesupport (= 3.2.15) +      builder (~> 3.0.0) +    activerecord (3.2.15) +      activemodel (= 3.2.15) +      activesupport (= 3.2.15) +      arel (~> 3.0.2) +      tzinfo (~> 0.3.29) +    activeresource (3.2.15) +      activemodel (= 3.2.15) +      activesupport (= 3.2.15) +    activesupport (3.2.15) +      i18n (~> 0.6, >= 0.6.4) +      multi_json (~> 1.0) +    addressable (2.3.5) +    arel (3.0.2) +    bootstrap-sass (2.1.1.0) +    bootswatch-rails (0.5.0) +      railties (>= 3.1) +    braintree (2.25.0) +      builder (>= 2.0.0) +    builder (3.0.4) +    capybara (2.1.0) +      mime-types (>= 1.16) +      nokogiri (>= 1.3.3) +      rack (>= 1.0.0) +      rack-test (>= 0.5.4) +      xpath (~> 2.0) +    client_side_validations (3.2.6) +    client_side_validations-simple_form (2.1.0) +      client_side_validations (~> 3.2.5) +      simple_form (~> 2.1.0) +    cliver (0.2.2) +    coffee-rails (3.2.2) +      coffee-script (>= 2.2.0) +      railties (~> 3.2.0) +    coffee-script (2.2.0) +      coffee-script-source +      execjs +    coffee-script-source (1.6.3) +    columnize (0.3.6) +    couchrest (1.1.3) +      mime-types (~> 1.15) +      multi_json (~> 1.0) +      rest-client (~> 1.6.1) +    couchrest_model (2.0.1) +      activemodel (>= 3.0) +      couchrest (~> 1.1.3) +      mime-types (>= 1.15) +      tzinfo (>= 0.3.22) +    couchrest_session_store (0.2.4) +      actionpack +      couchrest +      couchrest_model +    daemons (1.1.9) +    debugger (1.6.2) +      columnize (>= 0.3.1) +      debugger-linecache (~> 1.2.0) +      debugger-ruby_core_source (~> 1.2.3) +    debugger-linecache (1.2.0) +    debugger-ruby_core_source (1.2.4) +    erubis (2.7.0) +    eventmachine (1.0.3) +    execjs (2.0.2) +    factory_girl (4.2.0) +      activesupport (>= 3.0.0) +    factory_girl_rails (4.2.1) +      factory_girl (~> 4.2.0) +      railties (>= 3.0.0) +    fake_braintree (0.4) +      activesupport +      braintree (~> 2.5) +      capybara +      i18n +      sinatra +      thin +    faker (1.2.0) +      i18n (~> 0.5) +    haml (3.1.8) +    haml-rails (0.3.5) +      actionpack (>= 3.1, < 4.1) +      activesupport (>= 3.1, < 4.1) +      haml (~> 3.1) +      railties (>= 3.1, < 4.1) +    hike (1.2.3) +    i18n (0.6.9) +    journey (1.0.4) +    jquery-rails (3.0.4) +      railties (>= 3.0, < 5.0) +      thor (>= 0.14, < 2.0) +    json (1.8.1) +    kaminari (0.13.0) +      actionpack (>= 3.0.0) +      activesupport (>= 3.0.0) +      railties (>= 3.0.0) +    launchy (2.3.0) +      addressable (~> 2.3) +    libv8 (3.3.10.4) +    mail (2.5.4) +      mime-types (~> 1.16) +      treetop (~> 1.4.8) +    metaclass (0.0.1) +    mime-types (1.25.1) +    mini_portile (0.5.1) +    mocha (0.13.3) +      metaclass (~> 0.0.1) +    multi_json (1.8.2) +    nokogiri (1.6.0) +      mini_portile (~> 0.5.0) +    poltergeist (1.4.1) +      capybara (~> 2.1.0) +      cliver (~> 0.2.1) +      multi_json (~> 1.0) +      websocket-driver (>= 0.2.0) +    polyglot (0.3.3) +    quiet_assets (1.0.2) +      railties (>= 3.1, < 5.0) +    rack (1.4.5) +    rack-cache (1.2) +      rack (>= 0.4) +    rack-protection (1.5.0) +      rack +    rack-ssl (1.3.3) +      rack +    rack-test (0.6.2) +      rack (>= 1.0) +    rails (3.2.15) +      actionmailer (= 3.2.15) +      actionpack (= 3.2.15) +      activerecord (= 3.2.15) +      activeresource (= 3.2.15) +      activesupport (= 3.2.15) +      bundler (~> 1.0) +      railties (= 3.2.15) +    rails-i18n (3.0.0) +      i18n (~> 0.5) +      rails (>= 3.0.0, < 4.0.0) +    rails_warden (0.5.7) +      warden (>= 1.0.0) +    railties (3.2.15) +      actionpack (= 3.2.15) +      activesupport (= 3.2.15) +      rack-ssl (~> 1.3.2) +      rake (>= 0.8.7) +      rdoc (~> 3.4) +      thor (>= 0.14.6, < 2.0) +    rake (10.1.0) +    rdoc (3.12.2) +      json (~> 1.4) +    rest-client (1.6.7) +      mime-types (>= 1.16) +    ruby-srp (0.2.1) +    sass (3.2.12) +    sass-rails (3.2.6) +      railties (~> 3.2.0) +      sass (>= 3.1.10) +      tilt (~> 1.3) +    simple_form (2.1.0) +      actionpack (~> 3.0) +      activemodel (~> 3.0) +    sinatra (1.4.3) +      rack (~> 1.4) +      rack-protection (~> 1.4) +      tilt (~> 1.3, >= 1.3.4) +    sprockets (2.2.2) +      hike (~> 1.2) +      multi_json (~> 1.0) +      rack (~> 1.0) +      tilt (~> 1.1, != 1.3.0) +    therubyracer (0.10.2) +      libv8 (~> 3.3.10) +    thin (1.5.1) +      daemons (>= 1.0.9) +      eventmachine (>= 0.12.6) +      rack (>= 1.0.0) +    thor (0.18.1) +    tilt (1.4.1) +    treetop (1.4.15) +      polyglot +      polyglot (>= 0.3.1) +    tzinfo (0.3.38) +    uglifier (1.2.7) +      execjs (>= 0.3.0) +      multi_json (~> 1.3) +    warden (1.2.3) +      rack (>= 1.0) +    websocket-driver (0.3.0) +    xpath (2.0.0) +      nokogiri (~> 1.3) + +PLATFORMS +  ruby + +DEPENDENCIES +  SyslogLogger (~> 2.0) +  bootstrap-sass (~> 2.1.0) +  bootswatch-rails (~> 0.5.0) +  capybara +  certificate_authority! +  client_side_validations +  client_side_validations-simple_form +  coffee-rails (~> 3.2.2) +  debugger +  factory_girl_rails +  fake_braintree +  faker +  haml (~> 3.1.7) +  haml-rails (~> 0.3.4) +  jquery-rails +  kaminari (= 0.13.0) +  launchy +  leap_web_billing! +  leap_web_certs! +  leap_web_core! +  leap_web_help! +  leap_web_users! +  mocha (~> 0.13.0) +  poltergeist +  quiet_assets +  rails-i18n +  sass-rails (~> 3.2.5) +  simple_form +  therubyracer (~> 0.10.2) +  thin +  uglifier (~> 1.2.7) @@ -8,7 +8,7 @@ Install git, ruby 1.9, rubygems and couchdb on your system. Then run  ```  gem install bundler -git clone git://github.com/leapcode/leap_web.git +git clone https://leap.se/git/leap_web  cd leap_web  git submodule init  git submodule update @@ -36,7 +36,7 @@ The following packages need to be installed:  Simply clone the git repository:  ``` -  git clone git://github.com/leapcode/leap_web.git +  git clone https://leap.se/git/leap_web    cd leap_web  ``` @@ -41,7 +41,7 @@ Typically, this application is installed automatically as part of the LEAP Platf  ### Install system requirements -    sudo apt-get install git ruby1.8 rubygems1.8 couchdb +    sudo apt-get install git ruby1.9.3 rubygems couchdb      sudo gem install bundler  On Debian Wheezy or later, there is a Debian package for bundler, so you can alternately run ``sudo apt-get install bundler``. @@ -2,6 +2,8 @@  # Add your own tasks in files placed in lib/tasks ending in .rake,  # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. +RAKE=true   # let environment initialization code know if we are running via rake or not. +  require 'rake/packagetask'  require 'rubygems/package_task' diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index cd90934..03a40da 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -16,9 +16,8 @@  //= require bootstrap  //= require rails.validations  //= require rails.validations.simple_form -  //= require leap +//= require platform  //= require tickets  //= require users -  //= require_tree . diff --git a/app/assets/stylesheets/leap.scss b/app/assets/stylesheets/leap.scss index b382773..abfea05 100644 --- a/app/assets/stylesheets/leap.scss +++ b/app/assets/stylesheets/leap.scss @@ -43,25 +43,95 @@  }  // +// OS specific +// + +.os-android { +  display: none !important; +} + +html.android .os-android { +  display: inherit !important; +} + +.os-linux32 { +  display: none !important; +} + +html.linux32 .os-linux32 { +  display: inherit !important; +} + +.os-linux64 { +  display: none !important; +} + +html.linux64 .os-linux64 { +  display: inherit !important; +} + +.os-windows { +  display: none !important; +} + +html.windows .os-windows { +  display: inherit !important; +} + +.os-osx { +  display: none !important; +} + +html.osx .os-osx { +  display: inherit !important; +} + +.os-other { +  display: none !important; +} + +html.oldmac, html.oldwin, html.ios, html.fxos, html.other { +  .os-other { +    display: inherit !important; +  } +} + +//  // ICONS  // -[class^="big-icon-"], -[class*=" big-icon-"] { +[class*="-icon-"] {    display: inline-block; -  width: 32px; -  height: 32px;    @include ie7-restore-right-whitespace(); -  line-height: 32px;    vertical-align: middle;    background-repeat: no-repeat;    margin-top: 1px;  } +[class^="big-icon-"], +[class*=" big-icon-"] { +  width: 32px; +  height: 32px; +  line-height: 32px; +} + +[class^="huge-icon-"], +[class*=" huge-icon-"] { +  width: 128px; +  height: 128px; +  line-height: 128px; +} + +  .big-icon-arrow-down {    background-image: url(/leap-img/32/arrow-down.png)  } +.huge-icon-mask { +  height: 64px; +  background-image: url(/leap-img/128/mask.png) +} +  //  // TYPOGRAPHY  // @@ -152,6 +222,9 @@ input, textarea {    .download {      a.btn {        width: 14em; +      small { +        font-weight: normal; +      }      }    }    a.btn { @@ -191,4 +264,4 @@ input, textarea {  .overview li {    padding: 6px 0; -}
\ No newline at end of file +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b808e1c..de8d06b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,12 +10,14 @@ class ApplicationController < ActionController::Base    rescue_from StandardError do |e|      respond_to do |format| -      format.json { render_json_error } +      format.json { render_json_error(e) }        format.all  { raise e }  # reraise the exception so the normal thing happens.      end    end -  def render_json_error +  def render_json_error(e) +    Rails.logger.error e +    Rails.logger.error e.backtrace.join("\n")      render status: 500,        json: {error: "The server failed to process your request. We'll look into it."}    end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index be26eb6..1d62178 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -3,7 +3,7 @@ class HomeController < ApplicationController    def index      if logged_in? -      redirect_to user_overview_url(current_user) +      redirect_to current_user      end    end  end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1e79990..90e649a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,18 +19,25 @@ module ApplicationHelper    # http://twitter.github.io/bootstrap/base-css.html#icons    #    def icon(name, color=nil) +    "<i class=\"icon-#{name} #{color_class(color)}\"></i> ".html_safe +  end + +  def big_icon(name, color=nil) +    "<i class=\"big-icon-#{name} #{color_class(color)}\"></i> ".html_safe +  end + +  def huge_icon(name, color=nil) +    "<i class=\"huge-icon-#{name} #{color_class(color)}\"></i> ".html_safe +  end + +  def color_class(color)      if color.nil? -      color_class = nil +      nil      elsif color == :black -      color_class = 'icon-black' +      'icon-black'      elsif color == :white -      color_class = 'icon-white' +      'icon-white'      end -    "<i class=\"icon-#{name} #{color_class}\"></i> ".html_safe -  end - -  def big_icon(name, color=nil) -    "<i class=\"big-icon-#{name}\"></i> ".html_safe    end    def format_flash(msg) diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 79b515e..67b2c06 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -6,6 +6,10 @@        %p          We provide secure communication services, including encrypted internet, email (coming soon), and chat (coming later). +    .row-fluid +      .span6.offset3 +        = render 'layouts/messages' +    .row-fluid        = home_page_buttons        - if Rails.env == 'development' diff --git a/app/views/layouts/_navigation.html.haml b/app/views/layouts/_navigation.html.haml index b89655f..6de567a 100644 --- a/app/views/layouts/_navigation.html.haml +++ b/app/views/layouts/_navigation.html.haml @@ -1,7 +1,7 @@  %ul.nav.sidenav -  = link_to_navigation t(:overview), user_overview_path(@user), :active => controller?(:overviews) +  = link_to_navigation t(:overview), @user, :active => controller?(:overviews)    = link_to_navigation t(:account_settings), edit_user_path(@user), :active => controller?(:users)    - # will want link for identity settings    = link_to_navigation t(:support_tickets), auto_tickets_path, :active => controller?(:tickets) -  = link_to_navigation t(:billing_settings), show_or_new_customer_link(@user), :active => controller?(:customer, :payments, :subscriptions, :credit_card_info) if APP_CONFIG[:payment].present? +  = link_to_navigation t(:billing_settings), billing_top_link(@user), :active => controller?(:customer, :payments, :subscriptions, :credit_card_info) if APP_CONFIG[:payment].present?    = link_to_navigation t(:logout), logout_path, :method => :delete diff --git a/billing/Gemfile b/billing/Gemfile index 68ea51b..30e9669 100644 --- a/billing/Gemfile +++ b/billing/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org"  eval(File.read(File.dirname(__FILE__) + '/../common_dependencies.rb'))  eval(File.read(File.dirname(__FILE__) + '/../ui_dependencies.rb')) diff --git a/billing/app/controllers/billing_admin_controller.rb b/billing/app/controllers/billing_admin_controller.rb new file mode 100644 index 0000000..cd6149f --- /dev/null +++ b/billing/app/controllers/billing_admin_controller.rb @@ -0,0 +1,29 @@ +class BillingAdminController < BillingBaseController +  before_filter :authorize_admin + +  def show + +    br_atleast_90_days = Braintree::Subscription.search do |search| +      search.days_past_due >= 90 +    end +    @past_due_atleast_90_days = braintree_resource_collection_to_array(br_atleast_90_days) + +    br_all_past_due = Braintree::Subscription.search do |search| +      search.status.is Braintree::Subscription::Status::PastDue +      #cannot search by balance. +    end +    @all_past_due = braintree_resource_collection_to_array(br_all_past_due) + +  end + +  private + +  def braintree_resource_collection_to_array(braintree_resource_collection) +    array = [] +    braintree_resource_collection.each do |object| +      array << object +    end +    array +  end + +end diff --git a/billing/app/controllers/subscriptions_controller.rb b/billing/app/controllers/subscriptions_controller.rb index 7689f35..01aaab4 100644 --- a/billing/app/controllers/subscriptions_controller.rb +++ b/billing/app/controllers/subscriptions_controller.rb @@ -1,7 +1,9 @@  class SubscriptionsController < BillingBaseController    before_filter :authorize    before_filter :fetch_subscription, :only => [:show, :destroy] -  before_filter :confirm_no_active_subscription, :only => [:new, :create] +  before_filter :confirm_cancel_subscription, :only => [:destroy] +  before_filter :confirm_self_or_admin, :only => [:index] +  before_filter :confirm_no_pending_active_pastdue_subscription, :only => [:new, :create]    # for now, admins cannot create or destroy subscriptions for others:    before_filter :confirm_self, :only => [:new, :create] @@ -16,6 +18,7 @@ class SubscriptionsController < BillingBaseController    def create      @result = Braintree::Subscription.create( :payment_method_token => params[:payment_method_token], :plan_id => params[:plan_id] ) +    #if you want to test pastdue, can add :price => '2001', :trial_period => true,:trial_duration => 1,:trial_duration_unit => "day" and then wait a day    end    def destroy @@ -38,10 +41,14 @@ class SubscriptionsController < BillingBaseController    end -  def confirm_no_active_subscription +  def confirm_cancel_subscription +    access_denied unless view_context.allow_cancel_subscription(@subscription) +  end + +  def confirm_no_pending_active_pastdue_subscription      @customer = Customer.find_by_user_id(@user.id) -    if subscription = @customer.subscriptions # will return active subscription, if it exists -      redirect_to subscription_path(subscription.id), :notice => 'You already have an active subscription' +    if subscription = @customer.subscriptions # will return pending, active or pastdue subscription, if it exists +      redirect_to user_subscription_path(@user, subscription.id), :notice => 'You already have a subscription'      end    end @@ -49,4 +56,8 @@ class SubscriptionsController < BillingBaseController      @user == current_user    end +  def confirm_self_or_admin +    access_denied unless confirm_self or admin? +  end +  end diff --git a/billing/app/helpers/billing_helper.rb b/billing/app/helpers/billing_helper.rb index 3c0691f..b9e5e2e 100644 --- a/billing/app/helpers/billing_helper.rb +++ b/billing/app/helpers/billing_helper.rb @@ -9,6 +9,15 @@ module BillingHelper      form_for object, options, &block    end +  def billing_top_link(user) +    # for admins, top link will show special admin information, which has link to show their own customer information +    if (admin? and user == current_user) +      billing_admin_path +    else +      show_or_new_customer_link(user) +    end +  end +    def show_or_new_customer_link(user)      # Link to show if user is admin viewing another user, or user is already a customer.      # Otherwise link to create a new customer. @@ -19,4 +28,24 @@ module BillingHelper      end    end +  # a bit strange to put here, but we don't have a subscription model +  def user_for_subscription(subscription) + +    if (transaction = subscription.transactions.first) +      # much quicker, but will only work if there is already a transaction associated with subscription (should generally be) +      braintree_customer_id = transaction.customer_details.id +    else +      credit_card = Braintree::CreditCard.find(subscription.payment_method_token) +      braintree_customer_id = credit_card.customer_id +    end + +    customer = Customer.find_by_braintree_customer_id(braintree_customer_id) +    user = User.find(customer.user_id) + +  end + +  def allow_cancel_subscription(subscription) +    ['Active', 'Pending'].include? subscription.status or (admin? and subscription.status == 'Past Due') +  end +  end diff --git a/billing/app/models/customer.rb b/billing/app/models/customer.rb index f01c300..1acc7a5 100644 --- a/billing/app/models/customer.rb +++ b/billing/app/models/customer.rb @@ -40,19 +40,19 @@ class Customer < CouchRest::Model::Base    end    # based on 2nd parameter, either returns the single active subscription (or nil if there isn't one), or an array of all subsciptions -  def subscriptions(braintree_data=nil, only_active=true) +  def subscriptions(braintree_data=nil, only_pending_active_pastdue=true)      self.with_braintree_data!      return unless has_payment_info?      subscriptions = []      self.default_credit_card.subscriptions.each do |sub| -      if only_active and sub.status == 'Active' +      if only_pending_active_pastdue and ['Pending', 'Active','Past Due'].include? sub.status          return sub        else          subscriptions << sub        end      end -    only_active ? nil : subscriptions +    only_pending_active_pastdue ? nil : subscriptions    end  end diff --git a/billing/app/views/billing_admin/show.html.haml b/billing/app/views/billing_admin/show.html.haml new file mode 100644 index 0000000..0382cf0 --- /dev/null +++ b/billing/app/views/billing_admin/show.html.haml @@ -0,0 +1,7 @@ +%legend= t(:more_than_90_days_past_due) += render(:partial => "subscriptions/subscription_details", :collection => @past_due_atleast_90_days, :as => 'subscription', :locals => {:show_user => true}) || t(:none) +%legend= t(:all_past_due) += render(:partial => "subscriptions/subscription_details", :collection => @all_past_due, :as => 'subscription', :locals => {:show_user => true}) || t(:none) + +%legend= t(:your_settings) += link_to 'view own billing settings', show_or_new_customer_link(current_user)
\ No newline at end of file diff --git a/billing/app/views/customer/show.html.haml b/billing/app/views/customer/show.html.haml index d91a4e7..ec1779c 100644 --- a/billing/app/views/customer/show.html.haml +++ b/billing/app/views/customer/show.html.haml @@ -15,7 +15,7 @@      = render :partial => "subscriptions/subscription_details", :locals => {:subscription => @active_subscription}    - else      %p -      = t(:no_active_subscription) +      = t(:no_relevant_subscription)      - if current_user == @user        %p          .form-actions diff --git a/billing/app/views/subscriptions/_subscription_details.html.haml b/billing/app/views/subscriptions/_subscription_details.html.haml index 6eda7ca..6145c95 100644 --- a/billing/app/views/subscriptions/_subscription_details.html.haml +++ b/billing/app/views/subscriptions/_subscription_details.html.haml @@ -1,7 +1,14 @@  %p +  - if local_assigns[:show_user] +    User: +    - user_to_show = user_for_subscription(subscription) +    = link_to user_to_show.login, user_overview_path(user_to_show) +  ID:    = link_to subscription.id, user_subscription_path(@user, subscription.id)    Balance: -  = number_to_currency(subscription.balance) +  - color = (subscription.balance > 0) ? "red" : "" +  %font{:color => color} +    = number_to_currency(subscription.balance)    Bill on:    = subscription.billing_day_of_month    Start date: @@ -11,7 +18,7 @@    Plan:    = subscription.plan_id    Price: -  = subscription.price +  = number_to_currency(subscription.price)    - color = (subscription.status == 'Active') ? "green" : "red"    Status:    %font{:color => color} diff --git a/billing/app/views/subscriptions/index.html.haml b/billing/app/views/subscriptions/index.html.haml index 87771e5..3d4e8fd 100644 --- a/billing/app/views/subscriptions/index.html.haml +++ b/billing/app/views/subscriptions/index.html.haml @@ -1,8 +1,8 @@  %h2=t :all_subscriptions -- active = false +- pending_active_pastdue = false  - @subscriptions.each do |s| -  - if s.status == 'Active' -    - active = true +  - if ['Pending', 'Active','Past Due'].include? s.status +    - pending_active_pastdue = true    = render :partial => "subscription_details", :locals => {:subscription => s} -- if !active and @user == current_user +- if !pending_active_pastdue and @user == current_user    = link_to 'subscribe to plan', new_subscription_path, :class => :btn
\ No newline at end of file diff --git a/billing/app/views/subscriptions/show.html.haml b/billing/app/views/subscriptions/show.html.haml index 39f4d1a..2699db9 100644 --- a/billing/app/views/subscriptions/show.html.haml +++ b/billing/app/views/subscriptions/show.html.haml @@ -3,4 +3,4 @@      Current    Subscription  = render :partial => "subscription_details",  :locals => {:subscription => @subscription} -= link_to t(:cancel_subscription), user_subscription_path(@user, @subscription.id),  :confirm => t(:are_you_sure), :method => :delete, :class => 'btn btn-danger' if @subscription.status == 'Active' # permission check or should that just be on show? += link_to t(:cancel_subscription), user_subscription_path(@user, @subscription.id),  :confirm => t(:are_you_sure), :method => :delete, :class => 'btn btn-danger' if allow_cancel_subscription(@subscription) diff --git a/billing/config/locales/en.yml b/billing/config/locales/en.yml index eda7da2..b418a17 100644 --- a/billing/config/locales/en.yml +++ b/billing/config/locales/en.yml @@ -3,4 +3,5 @@ en:    must_create_customer: "You must store a customer in braintree before subscribing to a plan"    subscribe: "Subscribe"    save_customer_info: "Save Customer Information" -  donation_not_payment: "Note: This is a donation, and will not be applied towards your account."
\ No newline at end of file +  donation_not_payment: "Note: This is a donation, and will not be applied towards your account." +  no_relevant_subscription: "No subscription which is Active, Pending, or Past Due" diff --git a/billing/config/routes.rb b/billing/config/routes.rb index e024f43..dbdc24b 100644 --- a/billing/config/routes.rb +++ b/billing/config/routes.rb @@ -15,6 +15,7 @@ Rails.application.routes.draw do    match 'credit_card_info/confirm' => 'credit_card_info#confirm', :as => :confirm_credit_card_info    resources :subscriptions, :only => [:new, :create, :update] # index, show & destroy are within users path +  match 'billing_admin' => 'billing_admin#show', :as => :billing_admin    #match 'transactions/:product_id/new' => 'transactions#new', :as => :new_transaction    #match 'transactions/confirm/:product_id' => 'transactions#confirm', :as => :confirm_transaction diff --git a/billing/test/functional/customers_controller_test.rb b/billing/test/functional/customers_controller_test.rb index d4881bf..46c33c9 100644 --- a/billing/test/functional/customers_controller_test.rb +++ b/billing/test/functional/customers_controller_test.rb @@ -7,10 +7,11 @@ class CustomersControllerTest < ActionController::TestCase    setup do      @user = FactoryGirl.create :user      @other_user = FactoryGirl.create :user -    FakeBraintree.clear! -    FakeBraintree.verify_all_cards! +    #FakeBraintree.clear! +    #FakeBraintree.verify_all_cards!      testid = 'testid' -    FakeBraintree::Customer.new({:credit_cards => [{:number=>"5105105105105100", :expiration_date=>"05/2013"}]}, {:id => testid, :merchant_id => Braintree::Configuration.merchant_id}) +    #this wasn't actually being used +    #FakeBraintree::Customer.new({:credit_cards => [{:number=>"5105105105105100", :expiration_date=>"05/2013"}]}, {:id => testid, :merchant_id => Braintree::Configuration.merchant_id})      # any reason to call the create instance method on the FakeBraintree::Customer ?      @customer = Customer.new(:user_id => @other_user.id)      @customer.braintree_customer_id = testid @@ -50,6 +51,7 @@ class CustomersControllerTest < ActionController::TestCase    test "show" do +    skip "show customer"      login @other_user      # Below will fail, as when we go to fetch the customer data, Braintree::Customer.find(params[:id]) won't find the customer as it is a FakeBraintree customer.      #get :show, :id => @customer.braintree_customer_id diff --git a/billing/test/functional/subsciptions_controller_test.rb b/billing/test/functional/subscriptions_controller_test.rb index a6a1057..a6a1057 100644 --- a/billing/test/functional/subsciptions_controller_test.rb +++ b/billing/test/functional/subscriptions_controller_test.rb diff --git a/billing/test/integration/subscription_test.rb b/billing/test/integration/subscription_test.rb index b893896..6356177 100644 --- a/billing/test/integration/subscription_test.rb +++ b/billing/test/integration/subscription_test.rb @@ -10,28 +10,34 @@ class SubscriptionTest < ActionDispatch::IntegrationTest    setup do      Warden.test_mode! -    @admin = stub_record :user, :admin => true +    @admin = User.find_by_login('admin') || FactoryGirl.create(:user, login: 'admin')      @customer = stub_customer      @braintree_customer = @customer.braintree_customer      response = Braintree::Subscription.create plan_id: '5', -      payment_method_token: @braintree_customer.credit_cards.first.token +      payment_method_token: @braintree_customer.credit_cards.first.token, +      price: '10'      @subscription = response.subscription      Capybara.current_driver = Capybara.javascript_driver    end    teardown do      Warden.test_reset! +    @admin.destroy    end -  test "admin can see subscription for another" do +  test "admin can see all subscriptions for another" do      login_as @admin      @customer.stubs(:subscriptions).returns([@subscription]) +    @subscription.stubs(:balance).returns 0      visit user_subscriptions_path(@customer.user_id)      assert page.has_content?("Subscriptions")      assert page.has_content?("Status: Active")      page.save_screenshot('/tmp/subscriptions.png')    end +  # test "user cannot see all subscriptions for other user" do +  #end +    #test "admin cannot add subscription for another" do    #end diff --git a/certs/Gemfile b/certs/Gemfile index 951d1b7..992f236 100644 --- a/certs/Gemfile +++ b/certs/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org"  eval(File.read(File.dirname(__FILE__) + '/../common_dependencies.rb')) diff --git a/common_dependencies.rb b/common_dependencies.rb index 6a43e26..2225613 100644 --- a/common_dependencies.rb +++ b/common_dependencies.rb @@ -1,5 +1,3 @@ -source "http://rubygems.org" -  group :test do    # moching and stubing    gem 'mocha', '~> 0.13.0', :require => false diff --git a/config/application.rb b/config/application.rb index 8587ffc..2c9c55a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -78,12 +78,18 @@ module LeapWeb      # Enable the asset pipeline      config.assets.enabled = true -    config.assets.initialize_on_precompile = false +    config.assets.initialize_on_precompile = true # don't change this (see customization.rb)      # Version of your assets, change this if you want to expire all your assets      config.assets.version = '1.0'      # Set to false in order to see asset requests in the log      config.quiet_assets = true + +    ## +    ## CUSTOMIZATION +    ## see initializers/customization.rb +    ## +    config.paths['app/views'].unshift "config/customization/views"    end  end diff --git a/config/customization/README.md b/config/customization/README.md new file mode 100644 index 0000000..9c3e434 --- /dev/null +++ b/config/customization/README.md @@ -0,0 +1,27 @@ +Customizing LEAP Webapp +============================================ + +By default, this directory is empty. Any file you place here will override the default files for the application. + +For example: + +    stylesheets/ -- overrides files Rails.root/app/assets/stylesheets +      tail.scss -- included before all others +      head.scss -- included after all others + +    public/ -- overrides files in Rails.root/public +      favicon.ico -- custom favicon +      img/ -- customary directory to put images in + +    views/ -- overrides files Rails.root/app/views +      home/ +        index.html.haml -- this file is what shows up on the home page + +    locales/ -- overrides files in Rails.root/config/locales +      en.yml -- overrides for English +      de.yml -- overrides for German +      and so on... + +For most changes, the web application must be restarted after any changes are made to the customization directory. + +Sometimes a `rake tmp:clear` and a rails restart is required to pick up a new stylesheet. diff --git a/config/defaults.yml b/config/defaults.yml index 8d81668..260915e 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -13,34 +13,88 @@ cert_options: &cert_options    limited_cert_prefix: "LIMITED"    unlimited_cert_prefix: "UNLIMITED" +downloads: &downloads +  client_download_domain: https://downloads.leap.se +  available_clients: +    - linux32 +    - linux64 +    - osx +    - windows +    - android +  download_paths: +    android: /client/android/Bitmask-Android-latest.apk +    linux:   /client/linux +    linux32: /client/linux/Bitmask-linux32-latest.tar.bz2 +    linux64: /client/linux/Bitmask-linux64-latest.tar.bz2 +    osx:     /client/osx/Bitmask-OSX-latest.dmg +    windows: /client/windows/Bitmask-win32-latest.zip +    other:   /client +  common: &common    force_ssl: false    pagination_size: 30    auth:      token_expires_after: 60 +  # handles that will be blocked from being used as logins or email aliases +  # in addition to the ones in /etc/passwd and http://tools.ietf.org/html/rfc2142 +  handle_blacklist: [certmaster, ssladmin, arin-admin, administrator, www-data, maildrop] +  # handles that will be allowed despite being in /etc/passwd or rfc2142 +  handle_whitelist: [] +  # actions enabled in the account settings +  # see /users/app/views/users/_edit.html.haml for a list. +  user_actions: ['destroy_account'] +  admin_actions: ['change_pgp_key', 'change_service_level', 'destroy_account'] + +service_levels: &service_levels +  service_levels: +    0: +      name: anonymous +      cert_prefix: "LIMITED" +      description: "anonymous account, with rate limited VPN" +    1: +      name: free +      cert_prefix: "LIMITED" +      description: "free account, with rate limited VPN" +      cost: 0 +      quota: 100 +    2: +      name: premium +      cert_prefix: "UNLIMITED" +      description: "premium account, with unlimited vpn" +      cost: +        USD: 10 +        EUR: 10 +  default_service_level: 1  development: +  <<: *downloads    <<: *dev_ca    <<: *cert_options    <<: *common +  <<: *service_levels    admins: [blue, admin, admin2]    domain: example.org    secret_token: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'    payment: [] +  reraise_errors: true  test: +  <<: *downloads    <<: *dev_ca    <<: *cert_options    <<: *common +  <<: *service_levels    admins: [admin, admin2]    domain: test.me    secret_token: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'    payment: [billing] +  reraise_errors: true  production: +  <<: *downloads    <<: *cert_options    <<: *common    admins: []    domain: example.net    payment: [] -# logfile: /path/to/your/logs +  # logfile: /path/to/your/logs diff --git a/config/deploy.rb.example b/config/deploy.rb.example index 9e54c22..1fd4b8c 100644 --- a/config/deploy.rb.example +++ b/config/deploy.rb.example @@ -3,7 +3,7 @@ require "bundler/capistrano"  set :application, "webapp"  set :scm, :git -set :repository,  "git://leap.se/leap_web" +set :repository,  "https://leap.se/git/leap_web"  set :branch, "master"  set :deploy_via, :remote_cache diff --git a/config/initializers/customization.rb b/config/initializers/customization.rb new file mode 100644 index 0000000..bc9c834 --- /dev/null +++ b/config/initializers/customization.rb @@ -0,0 +1,36 @@ +# +# When deploying, common customizations can be dropped in config/customizations. This initializer makes this work. +# +customization_directory = "#{Rails.root}/config/customization" + +# +# Set customization views as the first view path +# +# Rails.application.config.paths['app/views'].unshift "config/customization/views" +# (For some reason, this does not work here. See application.rb for where this is actually called.) + +# +# Set customization stylesheets as the first asset path +# +# Some notes: +# +# * This cannot go in application.rb, as far as I can tell. In application.rb, the default paths +#   haven't been loaded yet, so the path we add will always end up at the end unless we add it here. +# +# * For this to work, config.assets.initialize_on_precompile MUST be set to true, otherwise +#   this initializer will never get called in production mode when the assets are precompiled. +# +Rails.application.config.assets.paths.unshift "#{customization_directory}/stylesheets" + +# +# Copy files to public +# +if !defined?(RAKE) && Dir.exists?("#{customization_directory}/public") +  require 'fileutils' +  FileUtils.cp_r("#{customization_directory}/public/.", "#{Rails.root}/public") +end + +# +# Add I18n path +# +Rails.application.config.i18n.load_path += Dir["#{customization_directory}/locales/*.{rb,yml,yaml}"] diff --git a/core/Gemfile b/core/Gemfile index 52ed377..b552dc5 100644 --- a/core/Gemfile +++ b/core/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org"  # Declare your gem's dependencies in leap_web_core.gemspec.  # Bundler will treat runtime dependencies like base dependencies, and diff --git a/core/app/assets/javascripts/platform.js b/core/app/assets/javascripts/platform.js new file mode 100644 index 0000000..3ab77d7 --- /dev/null +++ b/core/app/assets/javascripts/platform.js @@ -0,0 +1,92 @@ +/* Inspired by mozillas platform detection: +  https://github.com/mozilla/bedrock/tree/master/media/js/base +*/ + (function () { +   'use strict'; +   function getPlatform() { +     var ua = navigator.userAgent, +         pf = navigator.platform; +     if (/Win(16|9[x58]|NT( [1234]| 5\.0| [^0-9]|[^ -]|$))/.test(ua) || +       /Windows ([MC]E|9[x58]|3\.1|4\.10|NT( [1234]| 5\.0| [^0-9]|[^ ]|$))/.test(ua) || +       /Windows_95/.test(ua)) { +       /** +        * Officially unsupported platforms are Windows 95, 98, ME, NT 4.x, 2000 +        * These regular expressions match: +        *  - Win16 +        *  - Win9x +        *  - Win95 +        *  - Win98 +        *  - WinNT (not followed by version or followed by version <= 5) +        *  - Windows ME +        *  - Windows CE +        *  - Windows 9x +        *  - Windows 95 +        *  - Windows 98 +        *  - Windows 3.1 +        *  - Windows 4.10 +        *  - Windows NT (not followed by version or followed by version <= 5) +        *  - Windows_95 +        */ +       return 'oldwin'; +     } +     if (ua.indexOf("MSIE 6.0") !== -1 && +       ua.indexOf("Windows NT 5.1") !== -1 && +       ua.indexOf("SV1") === -1) { +       // Windows XP SP1 +       return 'oldwin'; +     } +     if (pf.indexOf("Win32") !== -1 || +       pf.indexOf("Win64") !== -1) { +       return 'windows'; +     } +     if (/android/i.test(ua)) { +       return 'android'; +     } +     if (/armv[6-7]l/.test(pf)) { +       return 'android'; +     } +     if (pf.indexOf("Linux") !== -1) { +       if (pf.indexOf("64") !== -1) { +         return 'linux64'; +       } else { +         return 'linux32'; +       } +     } +     if (pf.indexOf("MacPPC") !== -1) { +       return 'oldmac'; +     } +     if (/Mac OS X 10.[0-5]/.test(ua)) { +       return 'oldmac'; +     } +     if (pf.indexOf('iPhone') !== -1 || +       pf.indexOf('iPad') !== -1 || +       pf.indexOf('iPod') !== -1 ) { +       return 'ios'; +     } +     if (ua.indexOf("Mac OS X") !== -1) { +       return 'osx'; +     } +     if (ua.indexOf("MSIE 5.2") !== -1) { +       return 'oldmac'; +     } +     if (pf.indexOf("Mac") !== -1) { +       return 'oldmac'; +     } +     if (navigator.platform === '' && +       navigator.userAgent.indexOf("Firefox") !== -1 && +       navigator.userAgent.indexOf("Mobile") !== -1) { +       return 'fxos'; +     } + +     return 'other'; +   } +   (function () { +     // Immediately set the platform classname on the html-element +     // to avoid lots of flickering +     var h = document.documentElement; +     window.site = { +       platform : getPlatform() +     }; +     h.className = window.site.platform; +   })(); + })(); diff --git a/core/app/helpers/core_helper.rb b/core/app/helpers/core_helper.rb index a496144..4126906 100644 --- a/core/app/helpers/core_helper.rb +++ b/core/app/helpers/core_helper.rb @@ -10,4 +10,4 @@ module CoreHelper      render 'common/home_page_buttons'    end -end
\ No newline at end of file +end diff --git a/core/app/helpers/download_helper.rb b/core/app/helpers/download_helper.rb new file mode 100644 index 0000000..ee0fe73 --- /dev/null +++ b/core/app/helpers/download_helper.rb @@ -0,0 +1,33 @@ +module DownloadHelper + +  def alternative_client_links(os = nil) +    alternative_clients(os).map do |client| +      link_to(I18n.t("os."+client), client_download_url(client)) +    end +  end + +  def alternative_clients(os = nil) +    available_clients - [os] +  end + +  def client_download_url(os = nil) +    client_download_domain + client_download_path(os) +  end + +  def client_download_path(os) +    download_paths[os.to_s] || download_paths['other'] || '' +  end + +  def available_clients +    APP_CONFIG[:available_clients] || [] +  end + +  def client_download_domain +    APP_CONFIG[:client_download_domain] || '' +  end + +  def download_paths +    APP_CONFIG[:download_paths] || {} +  end + +end diff --git a/core/app/views/common/_download_for_os.html.haml b/core/app/views/common/_download_for_os.html.haml new file mode 100644 index 0000000..4c096ce --- /dev/null +++ b/core/app/views/common/_download_for_os.html.haml @@ -0,0 +1,16 @@ +- os = download_for_os +%div{:class => "os-#{os}"} +  %span.link +    - btn_class = (os == "other") ? "disabled" : "btn-primary" +    = link_to client_download_url(os), :class => "btn btn-large #{btn_class}" do +      .pull-left= huge_icon('mask') +      = t(:download_client) +      %br/ +      %small= I18n.t("os.#{os}") +  %span.info +    %div= t(:client_info, :provider => content_tag(:b,APP_CONFIG[:domain])).html_safe +    %div +    - if os == "other" +      = t(:all_downloads_info, :clients => alternative_client_links(os).to_sentence).html_safe +    - else +      = t(:other_downloads_info, :clients => alternative_client_links(os).to_sentence).html_safe diff --git a/core/app/views/common/_home_page_buttons.html.haml b/core/app/views/common/_home_page_buttons.html.haml index 7eb4c40..3be12e2 100644 --- a/core/app/views/common/_home_page_buttons.html.haml +++ b/core/app/views/common/_home_page_buttons.html.haml @@ -2,11 +2,14 @@  .home-buttons    .row-fluid.first -    .span3 -    .download.span6 -      %span.link= link_to(big_icon('arrow-down', icon_color) + t(:download_client), "https://downloads.leap.se/client", :class => 'btn btn-large') -      %span.info= t(:download_client_info, :provider => content_tag(:b,APP_CONFIG[:domain])).html_safe -    .span3 +    .span2 +    .download.span8 +      = render partial: 'common/download_for_os', collection: available_clients + ['other'] +    .span2 +  - if local_assigns[:divider] +    .row-fluid +      .span12 +        = render local_assigns[:divider]    .row-fluid.second      .login.span4        %span.link= link_to(icon('ok-sign', icon_color) + t(:login), login_path, :class => 'btn') diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index 25b377a..4abf4e8 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -21,10 +21,20 @@ en:    are_you_sure: "Are you sure? This change cannot be undone."    download_client: "Download Bitmask" -  download_client_info: "The Bitmask application allows you to use %{provider} services. It is available for Linux, Mac, Windows, and Android." +  client_info: "The Bitmask application allows you to use %{provider} services." +  all_downloads_info: "It is available for %{clients}." +  other_downloads_info: "Bitmask is also available for %{clients}."    login_info: "Log in to change your account settings, create support tickets, and manage payments."    signup_info: "Sign up for a new user account via this website (it is better if you use the Bitmask application to sign up, but this website works too)."    welcome: "Welcome to %{provider}."    get_help: "Get Help"    help_info: "Can't login? Create a new support ticket anonymously."    example_email: 'user@domain.org' +  os: +    linux32: "Linux (32 bit)" +    linux64: "Linux (64 bit)" +    windows: "Windows" +    android: "Android" +    osx: "Mac OS" +    other: "(not available for your OS.)" + diff --git a/core/leap_web_core.gemspec b/core/leap_web_core.gemspec index e98c892..7ca4d90 100644 --- a/core/leap_web_core.gemspec +++ b/core/leap_web_core.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |s|    s.add_dependency "couchrest", "~> 1.1.3"    s.add_dependency "couchrest_model", "~> 2.0.0" -  s.add_dependency "couchrest_session_store", "~> 0.2.0" +  s.add_dependency "couchrest_session_store", "~> 0.2.4"    s.add_dependency "json"  end diff --git a/core/lib/extensions/couchrest.rb b/core/lib/extensions/couchrest.rb index 91dfc1c..a9a195e 100644 --- a/core/lib/extensions/couchrest.rb +++ b/core/lib/extensions/couchrest.rb @@ -23,20 +23,18 @@ module CouchRest        end      end -    module Errors -      class ConnectionFailed < CouchRestModelError; end -    end -      module Connection        module ClassMethods          def use_database(db)            @database = prepare_database(db) -        rescue RestClient::Unauthorized, +        rescue RestClient::Exception,            Errno::EHOSTUNREACH,            Errno::ECONNREFUSED => e -          raise CouchRest::Model::Errors::ConnectionFailed.new(e.to_s) +          message = "Could not connect to couch database #{db} due to #{e.to_s}" +          Rails.logger.warn message +          raise e.class.new(message) if APP_CONFIG[:reraise_errors]          end        end @@ -47,28 +45,45 @@ module CouchRest          def self.load_all_models_with_engines            self.load_all_models_without_engines            return unless defined?(Rails) -          Dir[Rails.root + 'app/models/**/*.rb'].each do |path| -            require path -          end            Dir[Rails.root + '*/app/models/**/*.rb'].each do |path|              require path            end          end -        def self.all_models_and_proxies -          callbacks = migrate_each_model(find_models) -          callbacks += migrate_each_proxying_model(find_proxying_models) -          cleanup(callbacks) +        class << self +          alias_method_chain :load_all_models, :engines +        end + +        def dump_all_models +          prepare_directory +          find_models.each do |model| +            model.design_docs.each do |design| +              dump_design(model, design) +            end +          end          end +        protected +        def dump_design(model, design) +          dir = prepare_directory model.name.tableize +          filename = design.id.sub('_design/','') + '.json' +          puts dir + filename +          design.checksum +          File.open(dir + filename, "w") do |file| +            file.write(JSON.pretty_generate(design.to_hash)) +          end +        end -        class << self -          alias_method_chain :load_all_models, :engines +        def prepare_directory(dir = '') +          dir = Rails.root + 'tmp' + 'designs' + dir +          Dir.mkdir(dir) unless Dir.exists?(dir) +          return dir          end        end      end +    end    class ModelRailtie diff --git a/core/lib/tasks/leap_web_core_tasks.rake b/core/lib/tasks/leap_web_core_tasks.rake index ae5b79b..ec6abac 100644 --- a/core/lib/tasks/leap_web_core_tasks.rake +++ b/core/lib/tasks/leap_web_core_tasks.rake @@ -1,4 +1,25 @@ -# desc "Explaining what the task does" -# task :leap_web_core do -#   # Task goes here -# end +namespace :couchrest do + +  desc "Dump all the design docs found in each model" +  task :dump => :environment do +    CouchRest::Model::Utils::Migrate.load_all_models +    CouchRest::Model::Utils::Migrate.dump_all_models +  end +end + +namespace :cleanup do +   +  desc "Cleanup all expired session documents" +  task :sessions => :environment do +    # make sure this is the same as in +    #   config/initializers/session_store.rb +    store = CouchRest::Session::Store.new expire_after: 1800 +    store.cleanup(store.expired) +  end + +  desc "Cleanup all expired tokens" +  task :tokens => :environment do +    Token.destroy_all_expired +  end +end + diff --git a/help/Gemfile b/help/Gemfile index 5e895e9..ad7d29b 100644 --- a/help/Gemfile +++ b/help/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org"  eval(File.read(File.dirname(__FILE__) + '/../common_dependencies.rb'))  eval(File.read(File.dirname(__FILE__) + '/..//ui_dependencies.rb')) diff --git a/help/app/assets/javascripts/tickets.js b/help/app/assets/javascripts/tickets.js index bf7965c..18537aa 100644 --- a/help/app/assets/javascripts/tickets.js +++ b/help/app/assets/javascripts/tickets.js @@ -1,4 +1,4 @@  //$(document).ready(function () {  //  $.fn.editable.defaults.mode = 'inline'; -//  $('#title').editable(); +//  $('#subject').editable();  //});
\ No newline at end of file diff --git a/help/app/controllers/tickets_controller.rb b/help/app/controllers/tickets_controller.rb index a669e19..c193ff4 100644 --- a/help/app/controllers/tickets_controller.rb +++ b/help/app/controllers/tickets_controller.rb @@ -62,14 +62,11 @@ class TicketsController < ApplicationController          @ticket.comments.last.private = false unless admin?        end -      if @ticket.changed? -        if @ticket.save -          flash[:notice] = t(:changes_saved) -          redirect_to_tickets -        else -          respond_with @ticket -        end +      if @ticket.changed? and @ticket.save +        flash[:notice] = t(:changes_saved) +        redirect_to_tickets        else +        flash[:error] = @ticket.errors.full_messages.join(". ") if @ticket.changed?          redirect_to auto_ticket_path(@ticket)        end      end diff --git a/help/app/models/account_extension/tickets.rb b/help/app/models/account_extension/tickets.rb new file mode 100644 index 0000000..f898b56 --- /dev/null +++ b/help/app/models/account_extension/tickets.rb @@ -0,0 +1,13 @@ +module AccountExtension::Tickets +  extend ActiveSupport::Concern + +  def destroy_with_tickets +    Ticket.destroy_all_from(self.user) +    destroy_without_tickets +  end + +  included do +    alias_method_chain :destroy, :tickets +  end + +end diff --git a/help/app/models/ticket.rb b/help/app/models/ticket.rb index 8066d0d..cd22758 100644 --- a/help/app/models/ticket.rb +++ b/help/app/models/ticket.rb @@ -12,7 +12,7 @@ class Ticket < CouchRest::Model::Base    property :created_by,     String, :protected => true  # nil for anonymous tickets, should never be changed    property :regarding_user, String                      # may be nil or valid username -  property :title,          String +  property :subject,        String    property :email,          String    property :is_open,        TrueClass, :default => true    property :comments,       [TicketComment] @@ -24,6 +24,7 @@ class Ticket < CouchRest::Model::Base    design do      view :by_updated_at      view :by_created_at +    view :by_created_by      view :by_is_open_and_created_at      view :by_is_open_and_updated_at @@ -32,7 +33,7 @@ class Ticket < CouchRest::Model::Base      load_views(own_path.join('..', 'designs', 'ticket'))    end -  validates :title, :presence => true +  validates :subject, :presence => true    validates :email, :allow_blank => true, :format => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/    def self.search(options = {}) @@ -40,6 +41,12 @@ class Ticket < CouchRest::Model::Base      @selection.tickets    end +  def self.destroy_all_from(user) +    self.by_created_by.key(user.id).each do |ticket| +      ticket.destroy +    end +  end +    def is_creator_validated?      !!created_by    end diff --git a/help/app/views/tickets/_edit_form.html.haml b/help/app/views/tickets/_edit_form.html.haml index 5252c2e..714f8ff 100644 --- a/help/app/views/tickets/_edit_form.html.haml +++ b/help/app/views/tickets/_edit_form.html.haml @@ -1,7 +1,7 @@  :ruby    # created by user link    if @ticket.created_by_user -    created_by = link_to(@ticket.created_by_user.login, user_overview_path(@ticket.created_by_user)) +    created_by = link_to @ticket.created_by_user.login, @ticket.created_by_user    else      created_by = t(:anonymous)    end @@ -9,7 +9,7 @@    # regarding user link    if admin?      if @ticket.regarding_user_actual_user -      regarding_user_link = link_to @ticket.regarding_user_actual_user.login, user_overview_path(@ticket.regarding_user_actual_user) +      regarding_user_link = link_to @ticket.regarding_user_actual_user.login, @ticket.regarding_user_actual_user      else        regarding_user_link =  "(#{t(:unknown)})"      end @@ -26,7 +26,7 @@        %span.label.label-success= t(:closed)      %span.label.label-clear= t(:created_by_on, :user => created_by, :time => @ticket.created_at.to_s(:short)).html_safe    %div= t(:subject) -  = f.text_field :title, :class => 'large full-width' +  = f.text_field :subject, :class => 'large full-width'    .row-fluid      .span4        %div= t(:status) diff --git a/help/app/views/tickets/_ticket.html.haml b/help/app/views/tickets/_ticket.html.haml index a064c4e..5bc33c8 100644 --- a/help/app/views/tickets/_ticket.html.haml +++ b/help/app/views/tickets/_ticket.html.haml @@ -1,6 +1,6 @@  - url = auto_ticket_path(ticket)  %tr -  %td= link_to ticket.title, url +  %td= link_to ticket.subject, url    %td= link_to ticket.created_at.to_s(:short), url    %td= link_to ticket.updated_at.to_s(:short), url    %td= ticket.commenters diff --git a/help/app/views/tickets/new.html.haml b/help/app/views/tickets/new.html.haml index c0a343d..393e5d6 100644 --- a/help/app/views/tickets/new.html.haml +++ b/help/app/views/tickets/new.html.haml @@ -11,7 +11,7 @@  = simple_form_for @ticket, :validate => true, :html => {:class => 'form-horizontal'} do |f|    = hidden_ticket_fields -  = f.input :title, :label => t(:subject) +  = f.input :subject    - if logged_in?      = f.input :email, input_html: {value: email}      = f.input :regarding_user, input_html: {value: regarding} diff --git a/help/config/initializers/account_lifecycle.rb b/help/config/initializers/account_lifecycle.rb new file mode 100644 index 0000000..d9f04c1 --- /dev/null +++ b/help/config/initializers/account_lifecycle.rb @@ -0,0 +1,3 @@ +ActiveSupport.on_load(:account) do +  include AccountExtension::Tickets +end diff --git a/help/test/factories.rb b/help/test/factories.rb index 5b38952..be04f15 100644 --- a/help/test/factories.rb +++ b/help/test/factories.rb @@ -1,9 +1,17 @@  FactoryGirl.define do    factory :ticket do -    title { Faker::Lorem.sentence } -    comments_attributes do -      { "0" => { "body" => Faker::Lorem.sentences.join(" ") } } +    subject { Faker::Lorem.sentence } +    email { Faker::Internet.email } + +    factory :ticket_with_comment do +      comments_attributes do +        { "0" => { "body" => Faker::Lorem.sentences.join(" ") } } +      end +    end + +    factory :ticket_with_creator do +      created_by { FactoryGirl.create(:user).id }      end    end diff --git a/help/test/functional/tickets_controller_test.rb b/help/test/functional/tickets_controller_test.rb index 3747ad0..0f56e6e 100644 --- a/help/test/functional/tickets_controller_test.rb +++ b/help/test/functional/tickets_controller_test.rb @@ -53,7 +53,7 @@ class TicketsControllerTest < ActionController::TestCase    end    test "should create unauthenticated ticket" do -    params = {:title => "unauth ticket test title", :comments_attributes => {"0" => {"body" =>"body of test ticket"}}} +    params = {:subject => "unauth ticket test subject", :comments_attributes => {"0" => {"body" =>"body of test ticket"}}}      assert_difference('Ticket.count') do        post :create, :ticket => params @@ -70,7 +70,7 @@ class TicketsControllerTest < ActionController::TestCase    test "should create authenticated ticket" do -    params = {:title => "auth ticket test title", :comments_attributes => {"0" => {"body" =>"body of test ticket"}}} +    params = {:subject => "auth ticket test subject", :comments_attributes => {"0" => {"body" =>"body of test ticket"}}}      login diff --git a/help/test/unit/account_extension_test.rb b/help/test/unit/account_extension_test.rb new file mode 100644 index 0000000..aba162c --- /dev/null +++ b/help/test/unit/account_extension_test.rb @@ -0,0 +1,12 @@ +require 'test_helper' + +class AccountExtensionTest < ActiveSupport::TestCase + +  test "destroying an account triggers ticket destruction" do +    t = FactoryGirl.create :ticket_with_creator +    u = t.created_by_user +    Account.new(u).destroy +    assert_equal nil, Ticket.find(t.id) +  end + +end diff --git a/help/test/unit/ticket_comment_test.rb b/help/test/unit/ticket_comment_test.rb index 44865ed..fe8cc95 100644 --- a/help/test/unit/ticket_comment_test.rb +++ b/help/test/unit/ticket_comment_test.rb @@ -36,7 +36,7 @@ class TicketCommentTest < ActiveSupport::TestCase  =end    test "add comments" do -    testticket = Ticket.create :title => "testing" +    testticket = Ticket.create :subject => "testing"      assert_equal testticket.comments.count, 0      comment = TicketComment.new :body => "my email broke"      #assert comment.valid? #validating or saving necessary for setting posted_at diff --git a/help/test/unit/ticket_test.rb b/help/test/unit/ticket_test.rb index ce35e1d..f5e6ea7 100644 --- a/help/test/unit/ticket_test.rb +++ b/help/test/unit/ticket_test.rb @@ -1,57 +1,55 @@  require 'test_helper'  class TicketTest < ActiveSupport::TestCase -  #test "the truth" do -  #  assert true -  #end -  setup do -    @sample = Ticket.new +  test "ticket with default attribs is valid" do +    t = FactoryGirl.build :ticket +    assert t.valid?    end -  test "validity" do -    t = Ticket.create :title => 'test title', :email => 'blah@blah.com' +  test "ticket without email is valid" do +    t = FactoryGirl.build :ticket, email: ""      assert t.valid? -    assert_equal t.title, 'test title' +  end +  test "ticket validates email format" do +    t = FactoryGirl.build :ticket, email: "aswerssfd" +    assert !t.valid? +  end + +  test "ticket open states" do +    t = FactoryGirl.build :ticket      assert t.is_open      t.close      assert !t.is_open      t.reopen      assert t.is_open -    #user = LeapWebHelp::User.new(User.valid_attributes_hash) -    #user = LeapWebUsers::User.create - -    #t.user = user - -    #t.email = '' #invalid -    #assert !t.valid? -    #t.email = 'blah@blah.com, bb@jjj.org' -    #assert t.valid? -    t.email = 'bdlfjlkasfjklasjf' #invalid -    #p t.email_address -    #p t.email_address.strip =~ RFC822::EmailAddress -    assert !t.valid? -    t.reload.destroy    end    test "creation validated" do +    @sample = Ticket.new      assert !@sample.is_creator_validated?      #p current_user      @sample.created_by = 22 #current_user      assert @sample.is_creator_validated?    end +  test "destroy all tickets from a user" do +    t = FactoryGirl.create :ticket_with_creator +    u = t.created_by_user +    Ticket.destroy_all_from(u) +    assert_equal nil, Ticket.find(t.id) +  end  =begin  # TODO: do once have current_user stuff in order    test "code if & only if not creator-validated" do      User.current_test = nil -    t1 = Ticket.create :title => 'test title' +    t1 = Ticket.create :subject => 'test title'      assert_not_nil t1.code      assert_nil t1.created_by      User.current_test = 4 -    t2 = Ticket.create :title => 'test title' +    t2 = Ticket.create :subject => 'test title'      assert_nil t2.code      assert_not_nil t2.created_by    end @@ -66,7 +64,7 @@ class TicketTest < ActiveSupport::TestCase      # TODO: the by_includes_post_by view is only used for tests. Maybe we should get rid of it and change the test to including ordering? -    testticket = Ticket.create :title => "test retrieving commented tickets" +    testticket = Ticket.create :subject => "test retrieving commented tickets"      comment = TicketComment.new :body => "my email broke", :posted_by => "123"      assert_equal 0, testticket.comments.count      assert_equal [], Ticket.by_includes_post_by.key('123').all diff --git a/lib/leap_web/version.rb b/lib/leap_web/version.rb index a55c2ca..983e3ad 100644 --- a/lib/leap_web/version.rb +++ b/lib/leap_web/version.rb @@ -1,3 +1,3 @@  module LeapWeb -  VERSION = "0.2.4" unless defined?(LeapWeb::VERSION) +  VERSION = "0.2.8" unless defined?(LeapWeb::VERSION)  end diff --git a/public/leap-img/128/mask.png b/public/leap-img/128/mask.pngBinary files differ new file mode 100644 index 0000000..444a62c --- /dev/null +++ b/public/leap-img/128/mask.png diff --git a/test/integration/os_detection_test.rb b/test/integration/os_detection_test.rb new file mode 100644 index 0000000..cb254aa --- /dev/null +++ b/test/integration/os_detection_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' + +class OsDetectionTest < BrowserIntegrationTest + +  setup do +    Capybara.current_driver = Capybara.javascript_driver +  end + +  test "old windows shows deactivated download" do +    page.driver.headers = { "User-Agent" => "Win98" } +    visit '/' +    assert_selector "html.oldwin" +    assert has_text? "not available" +  end + +  test "android shows android download" do +    page.driver.headers = { "User-Agent" => "Android" } +    visit '/' +    assert_selector "html.android" +    assert has_no_text? "not available" +    assert_selector "small", text: "Android" +  end + +end diff --git a/users/Gemfile b/users/Gemfile index e30033a..4101ead 100644 --- a/users/Gemfile +++ b/users/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org"  eval(File.read(File.dirname(__FILE__) + '/../common_dependencies.rb'))  eval(File.read(File.dirname(__FILE__) + '/../ui_dependencies.rb')) diff --git a/users/app/assets/javascripts/srp b/users/app/assets/javascripts/srp -Subproject d22bf3b9fe2fd31192e1e1b358e97e5a0f3f90b +Subproject 8f33d32d40b1e21ae7fb9a92c78a275422af421 diff --git a/users/app/assets/javascripts/users.js b/users/app/assets/javascripts/users.js index aaeba6e..8486756 100644 --- a/users/app/assets/javascripts/users.js +++ b/users/app/assets/javascripts/users.js @@ -46,6 +46,13 @@      $(form).find('input[type="submit"]').button('loading');    }; +  resetButtons = function(submitEvent) { +    var form = $('form.submitted') +    // bootstrap loading state: +    $(form).find('input[type="submit"]').button('reset'); +    $(form).removeClass('submitted') +  }; +    //    // PUBLIC FUNCTIONS    // @@ -70,24 +77,36 @@    //    srp.error = function(message) {      clear_errors(); -    var element, error, field; +    var errors = extractErrors(message); +    displayErrors(errors); +    resetButtons(); +  } + +  function extractErrors(message) {      if ($.isPlainObject(message) && message.errors) { -      for (field in message.errors) { -        if (field == 'base') { -          alert_message(message.errors[field]); -          continue; -        } -        error = message.errors[field]; -        element = $('form input[name$="[' + field + ']"]'); -        if (!element) { -          continue; -        } -        element.trigger('element:validate:fail.ClientSideValidations', error).data('valid', false); -      } -    } else if (message.error) { -      alert_message(message.error); +      return message.errors;      } else { -      alert_message(JSON.stringify(message)); +      return { +        base: (message.error || JSON.stringify(message)) +      }; +    } +  } + +  function displayErrors(errors) { +    for (var field in errors) { +      var error = errors[field]; +      if (field === 'base') { +        alert_message(error); +      } else { +        displayFieldError(field, error); +      } +    } +  } + +  function displayFieldError(field, error) { +    var element = $('form input[name$="[' + field + ']"]'); +    if (element) { +      element.trigger('element:validate:fail.ClientSideValidations', error).data('valid', false);      }    }; diff --git a/users/app/controllers/keys_controller.rb b/users/app/controllers/keys_controller.rb new file mode 100644 index 0000000..fb28901 --- /dev/null +++ b/users/app/controllers/keys_controller.rb @@ -0,0 +1,18 @@ +class KeysController < ApplicationController + +  # +  # Render the user's key as plain text, without a layout. +  # +  # We will show blank page if user doesn't have key (which shouldn't generally occur) +  # and a 404 error if user doesn't exist +  # +  def show +    user = User.find_by_login(params[:login]) +    if user +      render text: user.public_key, content_type: 'text/text' +    else +      raise ActionController::RoutingError.new('Not Found') +    end +  end + +end diff --git a/users/app/controllers/overviews_controller.rb b/users/app/controllers/overviews_controller.rb deleted file mode 100644 index 52ce267..0000000 --- a/users/app/controllers/overviews_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -class OverviewsController < UsersBaseController - -  before_filter :authorize -  before_filter :fetch_user - -  def show -  end - -end diff --git a/users/app/controllers/sessions_controller.rb b/users/app/controllers/sessions_controller.rb index 0494b51..ca228c2 100644 --- a/users/app/controllers/sessions_controller.rb +++ b/users/app/controllers/sessions_controller.rb @@ -1,6 +1,7 @@  class SessionsController < ApplicationController    def new +    redirect_to root_path if logged_in?      @session = Session.new      if authentication_errors        @errors = authentication_errors @@ -14,12 +15,12 @@ class SessionsController < ApplicationController    end    # -  # this is a bad hack, but user_overview_url(user) is not available +  # this is a bad hack, but user_url(user) is not available    # also, this doesn't work because the redirect happens as a PUT. no idea why.    #    #Warden::Manager.after_authentication do |user, auth, opts|    #  response = Rack::Response.new -  #  response.redirect "/users/#{user.id}/overview" +  #  response.redirect "/users/#{user.id}"    # throw :warden, response.finish    #end diff --git a/users/app/controllers/users_controller.rb b/users/app/controllers/users_controller.rb index f66277d..0b32ec7 100644 --- a/users/app/controllers/users_controller.rb +++ b/users/app/controllers/users_controller.rb @@ -13,7 +13,7 @@ class UsersController < UsersBaseController    def index      if params[:query]        if @user = User.find_by_login(params[:query]) -        redirect_to user_overview_url(@user) +        redirect_to @user          return        else          @users = User.by_login.startkey(params[:query]).endkey(params[:query].succ) @@ -34,6 +34,12 @@ class UsersController < UsersBaseController    def edit    end +  ## added so updating service level works, but not sure we will actually want this. also not sure that this is place to prevent user from updating own effective service level, but here as placeholder: +  def update +    @user.update_attributes(params[:user]) unless (!admin? and params[:user][:effective_service_level]) +    respond_with @user +  end +    def deactivate      @user.enabled = false      @user.save @@ -47,8 +53,16 @@ class UsersController < UsersBaseController    end    def destroy -    @user.destroy -    redirect_to admin? ? users_url : root_url +    @user.account.destroy +    flash[:notice] = I18n.t(:account_destroyed) +    # admins can destroy other users +    if @user != current_user +      redirect_to users_url +    else +      # let's remove the invalid session +      logout +      redirect_to root_url +    end    end  end diff --git a/users/app/controllers/v1/users_controller.rb b/users/app/controllers/v1/users_controller.rb index 03a5a62..0903888 100644 --- a/users/app/controllers/v1/users_controller.rb +++ b/users/app/controllers/v1/users_controller.rb @@ -24,15 +24,9 @@ module V1      end      def update -      account.update params[:user] +      @user.account.update params[:user]        respond_with @user      end -    protected - -    def account -      @user.account -    end -    end  end diff --git a/users/app/models/account.rb b/users/app/models/account.rb index 5368a1b..cf998e4 100644 --- a/users/app/models/account.rb +++ b/users/app/models/account.rb @@ -1,5 +1,10 @@  # -# A Composition of a User record and it's identity records. +# The Account model takes care of the livecycle of a user. +# It composes a User record and it's identity records. +# It also allows for other engines to hook into the livecycle by +# monkeypatching the create, update and destroy methods. +# There's an ActiveSupport load_hook at the end of this file to +# make this more easy.  #  class Account @@ -22,16 +27,15 @@ class Account        @user.update_attributes attrs.slice(:password_verifier, :password_salt)      end      # TODO: move into identity controller -    update_pgp_key(attrs[:public_key]) if attrs.has_key? :public_key +    key = update_pgp_key(attrs[:public_key]) +    @user.errors.set :public_key, key.errors.full_messages      @user.save && save_identities      @user.refresh_identity    end    def destroy      return unless @user -    Identity.by_user_id.key(@user.id).each do |identity| -      identity.destroy -    end +    Identity.disable_all_for(@user)      @user.destroy    end @@ -46,12 +50,19 @@ class Account    end    def update_pgp_key(key) -    @new_identity ||= Identity.for(@user) -    @new_identity.set_key(:pgp, key) +    PgpKey.new(key).tap do |key| +      if key.present? && key.valid? +        @new_identity ||= Identity.for(@user) +        @new_identity.set_key(:pgp, key) +      end +    end    end    def save_identities      @new_identity.try(:save) && @old_identity.try(:save)    end +  # You can hook into the account lifecycle from different engines using +  #   ActiveSupport.on_load(:account) do ... +  ActiveSupport.run_load_hooks(:account, self)  end diff --git a/users/app/models/identity.rb b/users/app/models/identity.rb index e0a24e9..cbb540e 100644 --- a/users/app/models/identity.rb +++ b/users/app/models/identity.rb @@ -27,6 +27,17 @@ class Identity < CouchRest::Model::Base          emit(doc.address, doc.keys["pgp"]);        }      EOJS +    view :disabled, +      map: <<-EOJS +      function(doc) { +        if (doc.type != 'Identity') { +          return; +        } +        if (typeof doc.user_id === "undefined") { +          emit(doc._id, 1); +        } +      } +    EOJS    end @@ -50,6 +61,19 @@ class Identity < CouchRest::Model::Base      identity    end +  def self.disable_all_for(user) +    Identity.by_user_id.key(user.id).each do |identity| +      identity.disable +      identity.save +    end +  end + +  def self.destroy_all_disabled +    Identity.disabled.each do |identity| +      identity.destroy +    end +  end +    def self.attributes_from_user(user)      { user_id: user.id,        address: user.email_address, @@ -57,13 +81,22 @@ class Identity < CouchRest::Model::Base      }    end +  def enabled? +    self.destination && self.user_id +  end + +  def disable +    self.destination = nil +    self.user_id = nil +  end +    def keys      read_attribute('keys') || HashWithIndifferentAccess.new    end -  def set_key(type, value) -    return if keys[type] == value -    write_attribute('keys', keys.merge(type => value)) +  def set_key(type, key) +    return if keys[type] == key.to_s +    write_attribute('keys', keys.merge(type => key.to_s))    end    # for LoginFormatValidation @@ -93,7 +126,8 @@ class Identity < CouchRest::Model::Base    end    def destination_email -    return if destination.valid? #this ensures it is Email +    return if destination.nil?   # this identity is disabled +    return if destination.valid? # this ensures it is Email      self.errors.add(:destination, destination.errors.messages[:email].first) #assumes only one error #TODO    end diff --git a/users/app/models/local_email.rb b/users/app/models/local_email.rb index 6303bb6..2b4c65e 100644 --- a/users/app/models/local_email.rb +++ b/users/app/models/local_email.rb @@ -1,5 +1,10 @@  class LocalEmail < Email +  BLACKLIST_FROM_RFC2142 = [ +    'postmaster', 'hostmaster', 'domainadmin', 'webmaster', 'www', +    'abuse', 'noc', 'security', 'usenet', 'news', 'uucp', +    'ftp', 'sales', 'marketing', 'support', 'info' +  ]    def self.domain      APP_CONFIG[:domain] @@ -11,6 +16,8 @@ class LocalEmail < Email        :message => "needs to end in @#{domain}"      } +  validate :handle_allowed +    def initialize(s)      super      append_domain_if_needed @@ -32,4 +39,30 @@ class LocalEmail < Email      end    end +  def handle_allowed +    errors.add(:handle, "is reserved.") if handle_reserved? +  end + +  def handle_reserved? +    # *ARRAY in a case statement tests if ARRAY includes the handle. +    case handle +    when *APP_CONFIG[:handle_blacklist] +      true +    when *APP_CONFIG[:handle_whitelist] +      false +    when *BLACKLIST_FROM_RFC2142 +      true +    else +      handle_in_passwd? +    end +  end + +  def handle_in_passwd? +    begin +      !!Etc.getpwnam(handle) +    rescue ArgumentError +      # handle was not found +      return false +    end +  end  end diff --git a/users/app/models/pgp_key.rb b/users/app/models/pgp_key.rb new file mode 100644 index 0000000..66f8660 --- /dev/null +++ b/users/app/models/pgp_key.rb @@ -0,0 +1,48 @@ +class PgpKey +  include ActiveModel::Validations + +  KEYBLOCK_IDENTIFIERS = [ +    '-----BEGIN PGP PUBLIC KEY BLOCK-----', +    '-----END PGP PUBLIC KEY BLOCK-----', +  ] + +  # mostly for testing. +  attr_accessor :keyblock + +  validate :validate_keyblock_format + +  def initialize(keyblock = nil) +    @keyblock = keyblock +  end + +  def to_s +    @keyblock +  end + +  def present? +    @keyblock.present? +  end + +  # allow comparison with plain keyblock strings. +  def ==(other) +    self.equal?(other) or +    # relax the comparison on line ends. +    self.to_s.tr_s("\n\r", '') == other.tr_s("\r\n", '') +  end + +  protected + +  def validate_keyblock_format +    if keyblock_identifier_missing? +      errors.add :public_key_block, +        "does not look like an armored pgp public key block" +    end +  end + +  def keyblock_identifier_missing? +    KEYBLOCK_IDENTIFIERS.find do |identify| +      !@keyblock.include?(identify) +    end +  end + +end diff --git a/users/app/models/service_level.rb b/users/app/models/service_level.rb new file mode 100644 index 0000000..299aaf1 --- /dev/null +++ b/users/app/models/service_level.rb @@ -0,0 +1,19 @@ +class ServiceLevel + +  def initialize(attributes = {}) +    @id = attributes[:id] || APP_CONFIG[:default_service_level] +  end + +  def self.authenticated_select_options +    APP_CONFIG[:service_levels].map { |id,config_hash| [config_hash[:description], id] if config_hash[:name] != 'anonymous'}.compact +  end + +  def id +    @id +  end + +  def config_hash +    APP_CONFIG[:service_levels][@id] +  end + +end diff --git a/users/app/models/token.rb b/users/app/models/token.rb index dd87344..001eb40 100644 --- a/users/app/models/token.rb +++ b/users/app/models/token.rb @@ -11,6 +11,25 @@ class Token < CouchRest::Model::Base    validates :user_id, presence: true +  design do +    view :by_last_seen_at +  end + +  def self.expires_after +    APP_CONFIG[:auth] && APP_CONFIG[:auth][:token_expires_after] +  end + +  def self.expired +    return [] unless expires_after +    by_last_seen_at.endkey(expires_after.minutes.ago) +  end + +  def self.destroy_all_expired +    self.expired.each do |token| +      token.destroy +    end +  end +    def authenticate      if expired?        destroy @@ -27,21 +46,16 @@ class Token < CouchRest::Model::Base    end    def expired? -    expires_after and -    last_seen_at + expires_after.minutes < Time.now -  end - -  def expires_after -    APP_CONFIG[:auth] && APP_CONFIG[:auth][:token_expires_after] +    Token.expires_after and +    last_seen_at < Token.expires_after.minutes.ago    end    def initialize(*args)      super -    self.id = SecureRandom.urlsafe_base64(32).gsub(/^_*/, '') -    self.last_seen_at = Time.now -  end - -  design do +    if new_record? +      self.id = SecureRandom.urlsafe_base64(32).gsub(/^_*/, '') +      self.last_seen_at = Time.now +    end    end  end diff --git a/users/app/models/unauthenticated_user.rb b/users/app/models/unauthenticated_user.rb index 99a6874..0fc17d2 100644 --- a/users/app/models/unauthenticated_user.rb +++ b/users/app/models/unauthenticated_user.rb @@ -1,4 +1,6 @@  # The nil object for the user class  class UnauthenticatedUser < Object +  # will probably want something here to return service level as  APP_CONFIG[:service_levels][0] but not sure how will be accessing. +  end diff --git a/users/app/models/user.rb b/users/app/models/user.rb index a14fcb5..720f5a9 100644 --- a/users/app/models/user.rb +++ b/users/app/models/user.rb @@ -9,6 +9,12 @@ class User < CouchRest::Model::Base    property :enabled, TrueClass, :default => true +  # these will be null by default but we shouldn't ever pull them directly, but only via the methods that will return the full ServiceLevel +  property :desired_service_level_code, Integer, :accessible => true +  property :effective_service_level_code, Integer, :accessible => true + +  before_save :update_effective_service_level +    validates :login, :password_salt, :password_verifier,      :presence => true @@ -94,6 +100,16 @@ class User < CouchRest::Model::Base      @identity = Identity.for(self)    end +  def desired_service_level +    code = self.desired_service_level_code || APP_CONFIG[:default_service_level] +    ServiceLevel.new({id: code}) +  end + +  def effective_service_level +    code = self.effective_service_level_code || self.desired_service_level.id +    ServiceLevel.new({id: code}) +  end +    protected    ## @@ -116,4 +132,12 @@ class User < CouchRest::Model::Base    def serverside?      true    end + +  def update_effective_service_level +    # TODO: Is this always the case? Might there be a situation where the admin has set the effective service level and we don't want it changed to match the desired one? +    if self.desired_service_level_code_changed? +      self.effective_service_level_code = self.desired_service_level_code +    end +  end +  end diff --git a/users/app/views/overviews/show.html.haml b/users/app/views/overviews/show.html.haml deleted file mode 100644 index d3409df..0000000 --- a/users/app/views/overviews/show.html.haml +++ /dev/null @@ -1,22 +0,0 @@ -.overview - -  %h2.first= t(:overview_welcome, :username => @user.login) - -  - if admin? -    %p -      = t(:created) -      = @user.created_at -      %br -      = t(:updated) -      = @user.updated_at -      %br -      = t(:enabled) -      = @user.enabled? - -  %p= t(:overview_intro) - -  %ul.unstyled -    %li= icon('user') + link_to(t(:overview_account), edit_user_path(@user)) -    - # %li= icon('envelope') + link_to(t(:overview_email), {insert path for user identities, presuambly} -    %li= icon('question-sign') + link_to(t(:overview_tickets), user_tickets_path(@user)) -    %li= icon('shopping-cart') + link_to(t(:overview_billing), show_or_new_customer_link(@user)) if APP_CONFIG[:payment].present? diff --git a/users/app/views/users/_change_password.html.haml b/users/app/views/users/_change_password.html.haml new file mode 100644 index 0000000..425e3ee --- /dev/null +++ b/users/app/views/users/_change_password.html.haml @@ -0,0 +1,21 @@ +-# +-# CHANGE PASSWORD +-# +-# * everything about this form is handled with javascript. So take care when changing any ids. +-# * the login is required when changing the password because it is used as part of the salt when calculating the password verifier. +-#   however, we don't want the user to change their login without generating a new key, so we hide the ui for this +-#   (although it works perfectly fine to change username if the field was visible). +-# + +- form_options = {:url => '/not-used', :html => {:class => user_form_class('form-horizontal'), :id =>  'update_login_and_password', :data => {token: session[:token]}}, :validate => true} += simple_form_for @user, form_options do |f| +  %legend= t(:change_password) +  = hidden_field_tag 'user_param', @user.to_param +  .hidden +    = f.input :login, :label => t(:username), :required => false, :input_html => {:id => :srp_username} +  = f.input :password, :required => false, :validate => true, :input_html => { :id => :srp_password } +  = f.input :password_confirmation, :required => false, :input_html => { :id => :srp_password_confirmation } +  .control-group +    .controls +      = f.submit t(:save), :class => 'btn btn-primary' + diff --git a/users/app/views/users/_change_pgp_key.html.haml b/users/app/views/users/_change_pgp_key.html.haml new file mode 100644 index 0000000..e465125 --- /dev/null +++ b/users/app/views/users/_change_pgp_key.html.haml @@ -0,0 +1,13 @@ +-# +-# CHANGE PGP KEY +-# +-# this will be replaced by a identities controller/view at some point +-# + +- form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_pgp_key', :data => {token: session[:token]}}, :validate => true} += simple_form_for [:api, @user], form_options do |f| +  %legend= t(:advanced_options) +  = f.input :public_key, :as => :text, :hint => t(:use_ascii_key), :input_html => {:class => "full-width", :rows => 4} +  .control-group +    .controls +      = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} diff --git a/users/app/views/users/_change_service_level.html.haml b/users/app/views/users/_change_service_level.html.haml new file mode 100644 index 0000000..61e67d9 --- /dev/null +++ b/users/app/views/users/_change_service_level.html.haml @@ -0,0 +1,18 @@ +-# TODO: probably won't want here, but here for now. Also, we will need way to ensure payment if they pick a non-free plan. +-# +-# SERVICE LEVEL +-# +- if APP_CONFIG[:service_levels] +  - form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_service_level', :data => {token: session[:token]}}, :validate => true} +  = simple_form_for @user, form_options do |f| +    %legend= t(:service_level) +    - if @user != current_user +      = t(:desired_service_level) +    = f.select :desired_service_level_code, ServiceLevel.authenticated_select_options, :selected => @user.desired_service_level.id +    - if @user != current_user +      %p +      = t(:effective_service_level) +      = f.select :effective_service_level_code, ServiceLevel.authenticated_select_options, :selected => @user.effective_service_level.id +    .control-group +      .controls +        = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} diff --git a/users/app/views/users/_destroy_account.html.haml b/users/app/views/users/_destroy_account.html.haml new file mode 100644 index 0000000..445f3c4 --- /dev/null +++ b/users/app/views/users/_destroy_account.html.haml @@ -0,0 +1,27 @@ +-# +-# DESTROY ACCOUNT +-# + +%legend +  - if @user == current_user +    = t(:destroy_my_account) +  - else +    = t(:admin_destroy_account, :username => @user.login) +%p= t(:destroy_account_info) += link_to user_path(@user), :method => :delete, :confirm => t(:are_you_sure), :class => "btn btn-danger" do +  %i.icon-remove.icon-white +  = t(:destroy_my_account) +- if @user != current_user and @user.enabled? +  %legend +    = t(:deactivate_account, :username => @user.login) +  %p= t(:deactivate_description) +  = link_to deactivate_user_path(@user), :method => :post, :class => "btn btn-warning"  do +    %i.icon-pause.icon-white +    = t(:deactivate) +- elsif @user != current_user and !@user.enabled? +  %legend +    = t(:enable_account, :username => @user.login) +  %p= t(:enable_description) +  = link_to enable_user_path(@user), :method => :post, :class => "btn btn-warning"  do +    %i.icon-ok.icon-white +    = t(:enable) diff --git a/users/app/views/users/_edit.html.haml b/users/app/views/users/_edit.html.haml index 9d2473b..1d2b68a 100644 --- a/users/app/views/users/_edit.html.haml +++ b/users/app/views/users/_edit.html.haml @@ -1,66 +1,14 @@  -#  -# edit user form, used by both show and edit actions.  -# - --# --# CHANGE PASSWORD --# --# * everything about this form is handled with javascript. So take care when changing any ids. --# * the login is required when changing the password because it is used as part of the salt when calculating the password verifier. --#   however, we don't want the user to change their login without generating a new key, so we hide the ui for this --#   (although it works perfectly fine to change username if the field was visible). --# - -- form_options = {:url => '/not-used', :html => {:class => user_form_class('form-horizontal'), :id =>  'update_login_and_password', :data => {token: session[:token]}}, :validate => true} -= simple_form_for @user, form_options do |f| -  %legend= t(:change_password) -  = hidden_field_tag 'user_param', @user.to_param -  .hidden -    = f.input :login, :label => t(:username), :required => false, :input_html => {:id => :srp_username} -  = f.input :password, :required => false, :validate => true, :input_html => { :id => :srp_password } -  = f.input :password_confirmation, :required => false, :input_html => { :id => :srp_password_confirmation } -  .control-group -    .controls -      = f.submit t(:save), :class => 'btn btn-primary' - --# --# CHANGE PGP KEY --# --# this will be replaced by a identities controller/view at some point --# - -- form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_pgp_key', :data => {token: session[:token]}}, :validate => true} -= simple_form_for [:api, @user], form_options do |f| -  %legend= t(:advanced_options) -  = f.input :public_key, :as => :text, :hint => t(:use_ascii_key), :input_html => {:class => "full-width", :rows => 4} -  .control-group -    .controls -      = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} - --# --# DESTROY ACCOUNT --# - -%legend -  - if @user == current_user -    = t(:destroy_my_account) -  - else -    = t(:admin_destroy_account, :username => @user.login) -%p= t(:destroy_account_info) -= link_to user_path(@user), :method => :delete, :confirm => t(:are_you_sure), :class => "btn btn-danger" do -  %i.icon-remove.icon-white -  = t(:destroy_my_account) -- if @user != current_user and @user.enabled? -  %legend -    = t(:deactivate_account, :username => @user.login) -  %p= t(:deactivate_description) -  = link_to deactivate_user_path(@user), :method => :post, :class => "btn btn-warning"  do -    %i.icon-pause.icon-white -    = t(:deactivate) -- elsif @user != current_user and !@user.enabled? -  %legend -    = t(:enable_account, :username => @user.login) -  %p= t(:enable_description) -  = link_to enable_user_path(@user), :method => :post, :class => "btn btn-warning"  do -    %i.icon-ok.icon-white -      = t(:enable) +-# We render a bunch of forms here. Which we use depends upon config settings +-# user_actions and admin_actions. They both include an array of actions  +-# allowed to users and admins. +-# Possible forms are: +-#  'change_password' +-#  'change_pgp_key' +-#  'change_service_level' +-#  'destroy_account' +- actions = APP_CONFIG[admin? ? :admin_actions : :user_actions] || [] +- actions.each do |action| +  = render action diff --git a/users/app/views/users/_user.html.haml b/users/app/views/users/_user.html.haml index 990d9cf..583d22f 100644 --- a/users/app/views/users/_user.html.haml +++ b/users/app/views/users/_user.html.haml @@ -1,4 +1,4 @@  %tr -  %td= link_to user.login, user_overview_path(user) +  %td= link_to user.login, user    %td= l(user.created_at, :format => :short)    %td= l(user.updated_at, :format => :short) diff --git a/users/app/views/users/show.html.haml b/users/app/views/users/show.html.haml index 434c025..7bea370 100644 --- a/users/app/views/users/show.html.haml +++ b/users/app/views/users/show.html.haml @@ -1 +1,22 @@ -= render 'edit' +.overview + +  %h2.first= t(:overview_welcome, :username => @user.login) + +  - if admin? +    %p +      = t(:created) +      = @user.created_at +      %br +      = t(:updated) +      = @user.updated_at +      %br +      = t(:enabled) +      = @user.enabled? + +  %p= t(:overview_intro) + +  %ul.unstyled +    %li= icon('user') + link_to(t(:overview_account), edit_user_path(@user)) +    - # %li= icon('envelope') + link_to(t(:overview_email), {insert path for user identities, presuambly} +    %li= icon('question-sign') + link_to(t(:overview_tickets), user_tickets_path(@user)) +    %li= icon('shopping-cart') + link_to(t(:overview_billing), billing_top_link(@user)) if APP_CONFIG[:payment].present? diff --git a/users/config/locales/en.yml b/users/config/locales/en.yml index b69f7f4..1b5dd5e 100644 --- a/users/config/locales/en.yml +++ b/users/config/locales/en.yml @@ -17,6 +17,7 @@ en:    destroy_my_account: "Destroy my account"    destroy_account_info: "This will permanently destroy your account and all the data associated with it. Proceed with caution!"    admin_destroy_account: "Destroy the account %{username}" +  account_destroyed: "The account has been destroyed successfully."    set_email_address: "Set email address"    forward_email: "Forward Email"    email_aliases: "Email Aliases" diff --git a/users/config/routes.rb b/users/config/routes.rb index ccecfd5..de2ff37 100644 --- a/users/config/routes.rb +++ b/users/config/routes.rb @@ -13,13 +13,14 @@ Rails.application.routes.draw do    get "signup" => "users#new", :as => "signup"    resources :users, :except => [:create, :update] do -    resource :overview, :only => [:show]      # resource :email_settings, :only => [:edit, :update] -    resources :email_aliases, :only => [:destroy], :id => /.*/ +    # resources :email_aliases, :only => [:destroy], :id => /.*/      post 'deactivate', on: :member      post 'enable', on: :member    end    get "/.well-known/host-meta" => 'webfinger#host_meta'    get "/webfinger" => 'webfinger#search' +  get "/key/:login" => 'keys#show' +  end diff --git a/users/test/factories.rb b/users/test/factories.rb index c87e290..ae00d43 100644 --- a/users/test/factories.rb +++ b/users/test/factories.rb @@ -19,6 +19,16 @@ FactoryGirl.define do      end    end -  factory :token +  factory :token do +    user +  end +  factory :pgp_key do +    keyblock <<-EOPGP +-----BEGIN PGP PUBLIC KEY BLOCK----- ++Dummy+PGP+KEY+++Dummy+PGP+KEY+++Dummy+PGP+KEY+++Dummy+PGP+KEY+ +#{SecureRandom.base64(4032)} +-----END PGP PUBLIC KEY BLOCK----- +    EOPGP +  end  end diff --git a/users/test/functional/keys_controller_test.rb b/users/test/functional/keys_controller_test.rb new file mode 100644 index 0000000..863be93 --- /dev/null +++ b/users/test/functional/keys_controller_test.rb @@ -0,0 +1,32 @@ +require 'test_helper' + +class KeysControllerTest < ActionController::TestCase + +  test "get existing public key" do +    public_key = 'my public key' +    @user = stub_record :user, :public_key => public_key +    User.stubs(:find_by_login).with(@user.login).returns(@user) +    get :show, :login => @user.login +    assert_response :success +    assert_equal "text/text", response.content_type +    assert_equal public_key, response.body +  end + +  test "get non-existing public key for user" do +    # this isn't a scenerio that should generally occur. +    @user = stub_record :user +    User.stubs(:find_by_login).with(@user.login).returns(@user) +    get :show, :login => @user.login +    assert_response :success +    assert_equal "text/text", response.content_type +    assert_equal '', response.body.strip +  end + +  test "get public key for non-existing user" do +    # raise 404 error if user doesn't exist (doesn't need to be this routing error, but seems fine to assume for now): +    assert_raise(ActionController::RoutingError) { +      get :show, :login => 'asdkljslksjfdlskfj' +    } +  end + +end diff --git a/users/test/functional/sessions_controller_test.rb b/users/test/functional/sessions_controller_test.rb index a630e6e..8b49005 100644 --- a/users/test/functional/sessions_controller_test.rb +++ b/users/test/functional/sessions_controller_test.rb @@ -17,6 +17,13 @@ class SessionsControllerTest < ActionController::TestCase      assert_template "sessions/new"    end +  test "redirect to root_url if logged in" do +    login +    get :new +    assert_response :redirect +    assert_redirected_to root_url +  end +    test "renders json" do      get :new, :format => :json      assert_response :success @@ -41,20 +48,12 @@ class SessionsControllerTest < ActionController::TestCase      assert_json_error :login => I18n.t(:all_strategies_failed)    end -  test "logout should reset warden user" do -    expect_warden_logout +  test "destory should logout" do +    login +    expect_logout      delete :destroy      assert_response :redirect      assert_redirected_to root_url    end -  def expect_warden_logout -    raw = mock('raw session') do -      expects(:inspect) -    end -    request.env['warden'].expects(:raw_session).returns(raw) -    request.env['warden'].expects(:logout) -  end - -  end diff --git a/users/test/functional/users_controller_test.rb b/users/test/functional/users_controller_test.rb index 052de04..9c5f8d9 100644 --- a/users/test/functional/users_controller_test.rb +++ b/users/test/functional/users_controller_test.rb @@ -77,7 +77,11 @@ class UsersControllerTest < ActionController::TestCase    test "admin can destroy user" do      user = find_record :user + +    # we destroy the user record and the associated data...      user.expects(:destroy) +    Identity.expects(:disable_all_for).with(user) +    Ticket.expects(:destroy_all_from).with(user)      login :is_admin? => true      delete :destroy, :id => user.id @@ -88,9 +92,14 @@ class UsersControllerTest < ActionController::TestCase    test "user can cancel account" do      user = find_record :user + +    # we destroy the user record and the associated data...      user.expects(:destroy) +    Identity.expects(:disable_all_for).with(user) +    Ticket.expects(:destroy_all_from).with(user)      login user +    expect_logout      delete :destroy, :id => @current_user.id      assert_response :redirect diff --git a/users/test/functional/v1/sessions_controller_test.rb b/users/test/functional/v1/sessions_controller_test.rb index ff9fca1..4200e8f 100644 --- a/users/test/functional/v1/sessions_controller_test.rb +++ b/users/test/functional/v1/sessions_controller_test.rb @@ -52,26 +52,11 @@ class V1::SessionsControllerTest < ActionController::TestCase      assert_equal @user.id, token.user_id    end -  test "logout should reset session" do -    expect_warden_logout -    delete :destroy -    assert_response 204 -  end - -  test "logout should destroy token" do +  test "destroy should logout" do      login -    expect_warden_logout -    @token.expects(:destroy) +    expect_logout      delete :destroy      assert_response 204    end -  def expect_warden_logout -    raw = mock('raw session') do -      expects(:inspect) -    end -    request.env['warden'].expects(:raw_session).returns(raw) -    request.env['warden'].expects(:logout) -  end -  end diff --git a/users/test/integration/api/account_flow_test.rb b/users/test/integration/api/account_flow_test.rb index e41befa..edd0859 100644 --- a/users/test/integration/api/account_flow_test.rb +++ b/users/test/integration/api/account_flow_test.rb @@ -96,27 +96,41 @@ class AccountFlowTest < RackTest      assert server_auth["M2"]    end -  test "update user" do +  test "prevent changing login without changing password_verifier" do      server_auth = @srp.authenticate(self) -    test_public_key = 'asdlfkjslfdkjasd'      original_login = @user.login      new_login = 'zaph'      User.find_by_login(new_login).try(:destroy)      Identity.by_address.key(new_login + '@' + APP_CONFIG[:domain]).each do |identity|        identity.destroy      end -    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:public_key => test_public_key, :login => new_login}, :format => :json +    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:login => new_login}, :format => :json      assert last_response.successful? -    assert_equal test_public_key, Identity.for(@user).keys[:pgp]      # does not change login if no password_verifier is present      assert_equal original_login, @user.login -    # eventually probably want to remove most of this into a non-integration functional test -    # should not overwrite public key: -    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:blee => :blah}, :format => :json -    assert_equal test_public_key, Identity.for(@user).keys[:pgp] -    # should overwrite public key: -    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:public_key => nil}, :format => :json +  end + +  test "upload pgp key" do +    server_auth = @srp.authenticate(self) +    key = FactoryGirl.build :pgp_key +    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:public_key => key}, :format => :json +    assert_equal key, Identity.for(@user).keys[:pgp] +  end + +  # eventually probably want to remove most of this into a non-integration +  # functional test +  test "prevent uploading invalid key" do +    server_auth = @srp.authenticate(self) +    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:public_key => :blah}, :format => :json      assert_nil Identity.for(@user).keys[:pgp]    end +  test "prevent emptying public key" do +    server_auth = @srp.authenticate(self) +    key = FactoryGirl.build :pgp_key +    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:public_key => key}, :format => :json +    put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', :user => {:public_key => ""}, :format => :json +    assert_equal key, Identity.for(@user).keys[:pgp] +  end +  end diff --git a/users/test/integration/api/python/umlauts.py b/users/test/integration/api/python/umlauts.py new file mode 100755 index 0000000..96fecbf --- /dev/null +++ b/users/test/integration/api/python/umlauts.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +# coding: utf-8 + +# under development + +import requests +import json +import string +import random +import srp._pysrp as srp +import binascii + +safe_unhexlify = lambda x: binascii.unhexlify(x) if (len(x) % 2 == 0) else binascii.unhexlify('0'+x) + +# using globals for now +# server = 'https://dev.bitmask.net/1' +server = 'http://api.lvh.me:3000/1' + +def run_tests(): +  login = 'test_' + id_generator() +  password = id_generator() + "äöì" + id_generator() +  usr = srp.User( login, password, srp.SHA256, srp.NG_1024 ) +  print_and_parse(signup(login, password)) + +  auth = print_and_parse(authenticate(usr)) +  verify_or_debug(auth, usr) +  assert usr.authenticated() + + +# let's have some random name +def id_generator(size=6, chars=string.ascii_lowercase + string.digits): +  return ''.join(random.choice(chars) for x in range(size)) + +# log the server communication +def print_and_parse(response): +  request = response.request +  print request.method + ': ' + response.url +  if hasattr(request, 'data'): +    print "    " + json.dumps(response.request.data) +  print " -> " + response.text +  try:  +    return json.loads(response.text) +  except ValueError: +    return None + +def signup(login, password): +  salt, vkey = srp.create_salted_verification_key( login, password, srp.SHA256, srp.NG_1024 ) +  user_params = { +      'user[login]': login, +      'user[password_verifier]': binascii.hexlify(vkey), +      'user[password_salt]': binascii.hexlify(salt) +      } +  print json.dumps(user_params) +  return requests.post(server + '/users.json', data = user_params, verify = False) + +def authenticate(usr): +  session = requests.session() +  uname, A = usr.start_authentication() +  params = { +      'login': uname, +      'A': binascii.hexlify(A) +      } +  init = print_and_parse(session.post(server + '/sessions', data = params, verify=False)) +  M = usr.process_challenge( safe_unhexlify(init['salt']), safe_unhexlify(init['B']) ) +  return session.put(server + '/sessions/' + uname, verify = False, +      data = {'client_auth': binascii.hexlify(M)}) + +def verify_or_debug(auth, usr): +  if ( 'errors' in auth ): +    print '    u = "%x"' % usr.u +    print '    x = "%x"' % usr.x +    print '    v = "%x"' % usr.v +    print '    S = "%x"' % usr.S +    print '    K = "' + binascii.hexlify(usr.K) + '"' +    print '    M = "' + binascii.hexlify(usr.M) + '"' +  else: +    usr.verify_session( safe_unhexlify(auth["M2"]) ) + +run_tests() diff --git a/users/test/integration/browser/account_test.rb b/users/test/integration/browser/account_test.rb index 1deda45..4cefe35 100644 --- a/users/test/integration/browser/account_test.rb +++ b/users/test/integration/browser/account_test.rb @@ -6,6 +6,10 @@ class AccountTest < BrowserIntegrationTest      Capybara.current_driver = Capybara.javascript_driver    end +  teardown do +    Identity.destroy_all_disabled +  end +    test "normal account workflow" do      username, password = submit_signup      assert page.has_content?("Welcome #{username}") @@ -19,46 +23,85 @@ class AccountTest < BrowserIntegrationTest    test "successful login" do      username, password = submit_signup      click_on 'Logout' -    click_on 'Log In' -    fill_in 'Username', with: username -    fill_in 'Password', with: password -    click_on 'Log In' +    attempt_login(username, password)      assert page.has_content?("Welcome #{username}")      User.find_by_login(username).account.destroy    end -  test "change password" do +  test "failed login" do +    visit '/' +    attempt_login("username", "wrong password") +    assert_invalid_login(page) +  end + +  test "account destruction" do +    username, password = submit_signup +    click_on I18n.t('account_settings') +    click_on I18n.t('destroy_my_account') +    assert page.has_content?(I18n.t('account_destroyed')) +    attempt_login(username, password) +    assert_invalid_login(page) +  end + +  test "handle blocked after account destruction" do +    username, password = submit_signup +    click_on I18n.t('account_settings') +    click_on I18n.t('destroy_my_account') +    submit_signup(username) +    assert page.has_content?('has already been taken') +  end + +  test "default user actions" do      username, password = submit_signup      click_on "Account Settings" -    within('#update_login_and_password') do -      fill_in 'Password', with: "other password" -      fill_in 'Password confirmation', with: "other password" -      click_on 'Save' +    assert page.has_content? I18n.t('destroy_my_account') +    assert page.has_no_css? '#update_login_and_password' +    assert page.has_no_css? '#update_pgp_key' +  end + +  test "default admin actions" do +    username, password = submit_signup +    with_config admins: [username] do +      click_on "Account Settings" +      assert page.has_content? I18n.t('destroy_my_account') +      assert page.has_no_css? '#update_login_and_password' +      assert page.has_css? '#update_pgp_key' +    end +  end + +  test "change password" do +    with_config user_actions: ['change_password'] do +      username, password = submit_signup +      click_on "Account Settings" +      within('#update_login_and_password') do +        fill_in 'Password', with: "other password" +        fill_in 'Password confirmation', with: "other password" +        click_on 'Save' +      end +      click_on 'Logout' +      attempt_login(username, "other password") +      assert page.has_content?("Welcome #{username}") +      User.find_by_login(username).account.destroy      end -    click_on 'Logout' -    click_on 'Log In' -    fill_in 'Username', with: username -    fill_in 'Password', with: "other password" -    click_on 'Log In' -    assert page.has_content?("Welcome #{username}") -    User.find_by_login(username).account.destroy    end    test "change pgp key" do -    pgp_key = "My PGP Key Stub" -    username, password = submit_signup -    click_on "Account Settings" -    within('#update_pgp_key') do -      fill_in 'Public key', with: pgp_key -      click_on 'Save' +    with_config user_actions: ['change_pgp_key'] do +      pgp_key = FactoryGirl.build :pgp_key +      username, password = submit_signup +      click_on "Account Settings" +      within('#update_pgp_key') do +        fill_in 'Public key', with: pgp_key +        click_on 'Save' +      end +      page.assert_selector 'input[value="Saving..."]' +      # at some point we're done: +      page.assert_no_selector 'input[value="Saving..."]' +      assert page.has_field? 'Public key', with: pgp_key.to_s +      user = User.find_by_login(username) +      assert_equal pgp_key, user.public_key +      user.account.destroy      end -    page.assert_selector 'input[value="Saving..."]' -    # at some point we're done: -    page.assert_no_selector 'input[value="Saving..."]' -    assert page.has_field? 'Public key', with: pgp_key -    user = User.find_by_login(username) -    assert_equal pgp_key, user.public_key -    user.account.destroy    end @@ -81,6 +124,19 @@ class AccountTest < BrowserIntegrationTest      assert page.has_content?("server failed")    end +  def attempt_login(username, password) +    click_on 'Log In' +    fill_in 'Username', with: username +    fill_in 'Password', with: password +    click_on 'Log In' +  end + +  def assert_invalid_login(page) +    assert page.has_selector? 'input.btn-primary.disabled' +    assert page.has_content? I18n.t(:invalid_user_pass) +    assert page.has_no_selector? 'input.btn-primary.disabled' +  end +    def inject_malicious_js      page.execute_script <<-EOJS        var calc = new srp.Calculate(); diff --git a/users/test/support/auth_test_helper.rb b/users/test/support/auth_test_helper.rb index 609f115..50e9453 100644 --- a/users/test/support/auth_test_helper.rb +++ b/users/test/support/auth_test_helper.rb @@ -38,12 +38,26 @@ module AuthTestHelper      end    end +  def expect_logout +    expect_warden_logout +    @token.expects(:destroy) if @token +  end +    protected    def header_for_token_auth      @token = find_record(:token, :authenticate => @current_user)      ActionController::HttpAuthentication::Token.encode_credentials @token.id    end + +  def expect_warden_logout +    raw = mock('raw session') do +      expects(:inspect) +    end +    request.env['warden'].expects(:raw_session).returns(raw) +    request.env['warden'].expects(:logout) +  end +  end  class ActionController::TestCase diff --git a/users/test/support/integration_test_helper.rb b/users/test/support/integration_test_helper.rb index cfe72cf..51e47c6 100644 --- a/users/test/support/integration_test_helper.rb +++ b/users/test/support/integration_test_helper.rb @@ -1,7 +1,7 @@  module IntegrationTestHelper -  def submit_signup -    username = "test_#{SecureRandom.urlsafe_base64}".downcase -    password = SecureRandom.base64 +  def submit_signup(username = nil, password = nil) +    username ||= "test_#{SecureRandom.urlsafe_base64}".downcase +    password ||= SecureRandom.base64      visit '/users/new'      fill_in 'Username', with: username      fill_in 'Password', with: password diff --git a/users/test/unit/account_test.rb b/users/test/unit/account_test.rb index 94a9980..4fb3c3d 100644 --- a/users/test/unit/account_test.rb +++ b/users/test/unit/account_test.rb @@ -2,6 +2,10 @@ require 'test_helper'  class AccountTest < ActiveSupport::TestCase +  teardown do +    Identity.destroy_all_disabled +  end +    test "create a new account" do      user = Account.create(FactoryGirl.attributes_for(:user))      assert user.valid? @@ -13,7 +17,8 @@ class AccountTest < ActiveSupport::TestCase    end    test "create and remove a user account" do -    assert_no_difference "Identity.count" do +    # We keep an identity that will block the handle from being reused. +    assert_difference "Identity.count" do        assert_no_difference "User.count" do          user = Account.create(FactoryGirl.attributes_for(:user))          user.account.destroy diff --git a/users/test/unit/identity_test.rb b/users/test/unit/identity_test.rb index 0842a77..eca104f 100644 --- a/users/test/unit/identity_test.rb +++ b/users/test/unit/identity_test.rb @@ -90,6 +90,35 @@ class IdentityTest < ActiveSupport::TestCase      assert id.errors.messages[:destination].include? "needs to be a valid email address"    end +  test "disabled identity" do +    id = Identity.for(@user) +    id.disable +    assert_equal @user.email_address, id.address +    assert_equal nil, id.destination +    assert_equal nil, id.user +    assert !id.enabled? +    assert id.valid? +  end + +  test "disabled identity blocks handle" do +    id = Identity.for(@user) +    id.disable +    id.save +    other_user = find_record :user +    taken = Identity.build_for other_user, address: id.address +    assert !taken.valid? +    Identity.destroy_all_disabled +  end + +  test "destroy all disabled identities" do +    id = Identity.for(@user) +    id.disable +    id.save +    assert Identity.count > 0 +    Identity.destroy_all_disabled +    assert_equal 0, Identity.disabled.count +  end +    def alias_name      @alias_name ||= Faker::Internet.user_name    end diff --git a/users/test/unit/local_email_test.rb b/users/test/unit/local_email_test.rb index b25f46f..20ee7f1 100644 --- a/users/test/unit/local_email_test.rb +++ b/users/test/unit/local_email_test.rb @@ -24,6 +24,37 @@ class LocalEmailTest < ActiveSupport::TestCase      assert_equal ["needs to end in @#{LocalEmail.domain}"], local.errors[:email]    end +  test "blacklists rfc2142" do +    black_listed = LocalEmail.new('hostmaster') +    assert !black_listed.valid? +  end + +  test "blacklists etc passwd" do +    black_listed = LocalEmail.new('nobody') +    assert !black_listed.valid? +  end + +  test "whitelist overwrites automatic blacklists" do +    with_config handle_whitelist: ['nobody', 'hostmaster'] do +      white_listed = LocalEmail.new('nobody') +      assert white_listed.valid? +      white_listed = LocalEmail.new('hostmaster') +      assert white_listed.valid? +    end +  end + +  test "blacklists from config" do +    black_listed = LocalEmail.new('www-data') +    assert !black_listed.valid? +  end + +  test "blacklist from config overwrites whitelist" do +    with_config handle_whitelist: ['www-data'] do +      black_listed = LocalEmail.new('www-data') +      assert !black_listed.valid? +    end +  end +    def handle      @handle ||= Faker::Internet.user_name    end diff --git a/users/test/unit/token_test.rb b/users/test/unit/token_test.rb index f56c576..6c9f209 100644 --- a/users/test/unit/token_test.rb +++ b/users/test/unit/token_test.rb @@ -7,9 +7,6 @@ class ClientCertificateTest < ActiveSupport::TestCase      @user = find_record :user    end -  teardown do -  end -    test "new token for user" do      sample = Token.new(:user_id => @user.id)      assert sample.valid? @@ -61,6 +58,26 @@ class ClientCertificateTest < ActiveSupport::TestCase      end    end +  test "Token.destroy_all_expired is noop if no expiry is set" do +    expired = FactoryGirl.create :token, last_seen_at: 2.hours.ago +    with_config auth: {} do +      Token.destroy_all_expired +    end +    assert_equal expired, Token.find(expired.id) +  end + +  test "Token.destroy_all_expired cleans up expired tokens only" do +    expired = FactoryGirl.create :token, last_seen_at: 2.hours.ago +    fresh = FactoryGirl.create :token +    with_config auth: {token_expires_after: 60} do +      Token.destroy_all_expired +    end +    assert_nil Token.find(expired.id) +    assert_equal fresh, Token.find(fresh.id) +    fresh.destroy +  end + +  end | 
