From 12c2af26bbf474484f6c93a3cf496a6d1dae30f9 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Wed, 14 May 2014 22:36:14 +0200 Subject: [PATCH 01/10] Add omniauth to let huginn consume oauth endpoints Created a service model which stores external account information. Created a service controller which kicks off the oauth process and lets the user manage their external accounts. Oauthable concern connects a agent to a service and adds a drop down to the agents edit page. Added a migration which converts the existing twitter agents to use the new service model. --- .env.example | 12 ++ .travis.yml | 2 +- Gemfile | 5 + Gemfile.lock | 25 +++ app/concerns/oauthable.rb | 32 ++++ app/concerns/twitter_concern.rb | 10 +- app/controllers/services_controller.rb | 40 +++++ app/models/agent.rb | 1 + app/models/agents/basecamp_agent.rb | 22 +-- app/models/service.rb | 59 +++++++ app/models/user.rb | 6 + app/views/agents/_form.html.erb | 9 +- app/views/layouts/_navigation.html.erb | 1 + app/views/services/index.html.erb | 52 +++++++ config/initializers/omniauth.rb | 5 + config/routes.rb | 7 + db/migrate/20140515211100_create_services.rb | 18 +++ ...20140525150040_add_service_id_to_agents.rb | 5 + ...igrate_agents_to_service_authentication.rb | 39 +++++ db/schema.rb | 145 ++++++++++-------- spec/controllers/services_controller_spec.rb | 57 +++++++ spec/data_fixtures/services/37signals.json | 43 ++++++ spec/data_fixtures/services/github.json | 52 +++++++ spec/data_fixtures/services/twitter.json | 66 ++++++++ spec/fixtures/services.yml | 17 ++ spec/models/agents/basecamp_agent_spec.rb | 28 +--- .../agents/twitter_publish_agent_spec.rb | 1 + .../agents/twitter_stream_agent_spec.rb | 1 + spec/models/agents/twitter_user_agent_spec.rb | 1 + spec/models/concerns/oauthable.rb | 29 ++++ spec/models/service_spec.rb | 100 ++++++++++++ spec/spec_helper.rb | 2 + 32 files changed, 788 insertions(+), 104 deletions(-) create mode 100644 app/concerns/oauthable.rb create mode 100644 app/controllers/services_controller.rb create mode 100644 app/models/service.rb create mode 100644 app/views/services/index.html.erb create mode 100644 config/initializers/omniauth.rb create mode 100644 db/migrate/20140515211100_create_services.rb create mode 100644 db/migrate/20140525150040_add_service_id_to_agents.rb create mode 100644 db/migrate/20140525150140_migrate_agents_to_service_authentication.rb create mode 100644 spec/controllers/services_controller_spec.rb create mode 100644 spec/data_fixtures/services/37signals.json create mode 100644 spec/data_fixtures/services/github.json create mode 100644 spec/data_fixtures/services/twitter.json create mode 100644 spec/fixtures/services.yml create mode 100644 spec/models/concerns/oauthable.rb create mode 100644 spec/models/service_spec.rb diff --git a/.env.example b/.env.example index 7a0c727a..7bc2f79c 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,18 @@ EMAIL_FROM_ADDRESS=from_address@gmail.com # Number of lines of log messages to keep per Agent AGENT_LOG_LENGTH=200 +############################# +# OAuth Configuration # +############################# +TWITTER_OAUTH_KEY= +TWITTER_OAUTH_SECRET= + +37SIGNALS_OAUTH_KEY= +37SIGNALS_OAUTH_SECRET= + +GITHUB_OAUTH_KEY= +GITHUB_OAUTH_SECRET= + ############################# # AWS and Mechanical Turk # ############################# diff --git a/.travis.yml b/.travis.yml index b4378bdf..3ee16fd5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: ruby bundler_args: --without development production env: - - APP_SECRET_TOKEN=b2724973fd81c2f4ac0f92ac48eb3f0152c4a11824c122bcf783419a4c51d8b9bba81c8ba6a66c7de599677c7f486242cf819775c433908e77c739c5c8ae118d + - APP_SECRET_TOKEN=b2724973fd81c2f4ac0f92ac48eb3f0152c4a11824c122bcf783419a4c51d8b9bba81c8ba6a66c7de599677c7f486242cf819775c433908e77c739c5c8ae118d TWITTER_OAUTH_KEY=twitteroauthkey TWITTER_OAUTH_SECRET=twitteroauthsecret rvm: - 2.0.0 - 2.1.1 diff --git a/Gemfile b/Gemfile index 420b8f8c..2463c7e2 100644 --- a/Gemfile +++ b/Gemfile @@ -76,6 +76,11 @@ gem 'therubyracer', '~> 0.12.1' gem 'mqtt' +gem 'omniauth' +gem 'omniauth-twitter' +gem 'omniauth-37signals' +gem 'omniauth-github' + group :development do gem 'binding_of_caller' gem 'better_errors' diff --git a/Gemfile.lock b/Gemfile.lock index 6eb84004..10e78f62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -168,12 +168,33 @@ GEM naught (1.0.0) nokogiri (1.6.2.1) mini_portile (= 0.6.0) + oauth (0.4.7) oauth2 (0.9.3) faraday (>= 0.8, < 0.10) jwt (~> 0.1.8) multi_json (~> 1.3) multi_xml (~> 0.5) rack (~> 1.2) + omniauth (1.2.1) + hashie (>= 1.2, < 3) + rack (~> 1.0) + omniauth-37signals (1.0.5) + omniauth (~> 1.0) + omniauth-oauth2 (~> 1.0) + omniauth-github (1.1.2) + omniauth (~> 1.0) + omniauth-oauth2 (~> 1.1) + omniauth-oauth (1.0.1) + oauth + omniauth (~> 1.0) + omniauth-oauth2 (1.1.2) + faraday (>= 0.8, < 0.10) + multi_json (~> 1.3) + oauth2 (~> 0.9.3) + omniauth (~> 1.2) + omniauth-twitter (1.0.1) + multi_json (~> 1.3) + omniauth-oauth (~> 1.0) orm_adapter (0.5.0) polyglot (0.3.5) protected_attributes (1.0.7) @@ -345,6 +366,10 @@ DEPENDENCIES mqtt mysql2 (~> 0.3.15) nokogiri (~> 1.6.1) + omniauth + omniauth-37signals + omniauth-github + omniauth-twitter protected_attributes (~> 1.0.7) pry rack diff --git a/app/concerns/oauthable.rb b/app/concerns/oauthable.rb new file mode 100644 index 00000000..57f523dc --- /dev/null +++ b/app/concerns/oauthable.rb @@ -0,0 +1,32 @@ +module Oauthable + extend ActiveSupport::Concern + + included do |base| + attr_accessible :service_id + validates_presence_of :service_id + base.extend ClassMethods + self.class_variable_set(:@@valid_oauth_providers, :all) + end + + def oauthable? + true + end + + def valid_services(current_user) + if valid_oauth_providers == :all + current_user.available_services + else + current_user.available_services.where(provider: valid_oauth_providers) + end + end + + def valid_oauth_providers + self.class.class_variable_get(:@@valid_oauth_providers) + end + + module ClassMethods + def valid_oauth_providers(*providers) + self.class_variable_set(:@@valid_oauth_providers, providers) + end + end +end diff --git a/app/concerns/twitter_concern.rb b/app/concerns/twitter_concern.rb index e6fb8841..132338db 100644 --- a/app/concerns/twitter_concern.rb +++ b/app/concerns/twitter_concern.rb @@ -1,8 +1,10 @@ module TwitterConcern extend ActiveSupport::Concern + include Oauthable included do validate :validate_twitter_options + valid_oauth_providers :twitter end def validate_twitter_options @@ -15,19 +17,19 @@ module TwitterConcern end def twitter_consumer_key - options['consumer_key'].presence || credential('twitter_consumer_key') + ENV['TWITTER_OAUTH_KEY'] end def twitter_consumer_secret - options['consumer_secret'].presence || credential('twitter_consumer_secret') + ENV['TWITTER_OAUTH_SECRET'] end def twitter_oauth_token - options['oauth_token'].presence || options['access_key'].presence || credential('twitter_oauth_token') + self.service.token end def twitter_oauth_token_secret - options['oauth_token_secret'].presence || options['access_secret'].presence || credential('twitter_oauth_token_secret') + self.service.secret end def twitter diff --git a/app/controllers/services_controller.rb b/app/controllers/services_controller.rb new file mode 100644 index 00000000..03677bcd --- /dev/null +++ b/app/controllers/services_controller.rb @@ -0,0 +1,40 @@ +class ServicesController < ApplicationController + + def index + @services = current_user.services.page(params[:page]) + + respond_to do |format| + format.html + format.json { render json: @services } + end + end + + def destroy + @services = current_user.services.find(params[:id]) + @services.destroy + + respond_to do |format| + format.html { redirect_to services_path } + format.json { head :no_content } + end + end + + def toggle_availability + @service = current_user.services.find(params[:id]) + @service.toggle_availability! + + respond_to do |format| + format.html { redirect_to services_path } + format.json { render json: @service } + end + end + + def callback + @service = current_user.services.initialize_or_update_via_omniauth(request.env['omniauth.auth']) + if @service && @service.save + redirect_to services_path, notice: "The service was successfully created." + else + redirect_to services_path, error: "Error creating the service." + end + end +end diff --git a/app/models/agent.rb b/app/models/agent.rb index 3bc4172b..271c14cc 100644 --- a/app/models/agent.rb +++ b/app/models/agent.rb @@ -40,6 +40,7 @@ class Agent < ActiveRecord::Base after_save :possibly_update_event_expirations belongs_to :user, :inverse_of => :agents + belongs_to :service has_many :events, -> { order("events.id desc") }, :dependent => :delete_all, :inverse_of => :agent has_one :most_recent_event, :inverse_of => :agent, :class_name => "Event", :order => "events.id desc" has_many :logs, -> { order("agent_logs.id desc") }, :dependent => :delete_all, :inverse_of => :agent, :class_name => "AgentLog" diff --git a/app/models/agents/basecamp_agent.rb b/app/models/agents/basecamp_agent.rb index 87bb8b81..83cbd674 100644 --- a/app/models/agents/basecamp_agent.rb +++ b/app/models/agents/basecamp_agent.rb @@ -2,17 +2,16 @@ module Agents class BasecampAgent < Agent cannot_receive_events! + include Oauthable + valid_oauth_providers '37signals' + description <<-MD The BasecampAgent checks a Basecamp project for new Events - It is required that you enter your Basecamp credentials (`username` and `password`). - - You also need to provide your Basecamp `user_id` and the `project_id` of the project you want to monitor. + You need to provide the `project_id` of the project you want to monitor. If you have your Basecamp project opened in your browser you can find the user_id and project_id as follows: - `https://basecamp.com/` - user_id - `/projects/` + `https://basecamp.com/123456/projects/` project_id `-explore-basecamp` MD @@ -45,17 +44,11 @@ module Agents def default_options { - 'username' => '', - 'password' => '', - 'user_id' => '', 'project_id' => '', } end def validate_options - errors.add(:base, "you need to specify your basecamp username") unless options['username'].present? - errors.add(:base, "you need to specify your basecamp password") unless options['password'].present? - errors.add(:base, "you need to specify your basecamp user id") unless options['user_id'].present? errors.add(:base, "you need to specify the basecamp project id of which you want to receive events") unless options['project_id'].present? end @@ -64,6 +57,7 @@ module Agents end def check + self.service.prepare_request reponse = HTTParty.get request_url, request_options.merge(query_parameters) memory[:last_run] = Time.now.utc.iso8601 if last_check_at != nil @@ -76,11 +70,11 @@ module Agents private def request_url - "https://basecamp.com/#{URI.encode(options[:user_id].to_s)}/api/v1/projects/#{URI.encode(options[:project_id].to_s)}/events.json" + "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(options[:project_id].to_s)}/events.json" end def request_options - {:basic_auth => {:username =>options[:username], :password=>options[:password]}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}} + {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => "Bearer \"#{self.service.token}\""}} end def query_parameters diff --git a/app/models/service.rb b/app/models/service.rb new file mode 100644 index 00000000..a2ceedfc --- /dev/null +++ b/app/models/service.rb @@ -0,0 +1,59 @@ +class Service < ActiveRecord::Base + attr_accessible :provider, :name, :token, :secret, :refresh_token, :expires_at, :global, :options + + serialize :options, Hash + + belongs_to :user + + validates_presence_of :user_id, :provider, :name, :token + + def toggle_availability! + self.global = !self.global + self.save! + end + + def prepare_request + if self.expires_at && Time.now > self.expires_at + self.refresh_token! + end + end + + def refresh_token! + response = HTTParty.post(endpoint, query: { + type: 'refresh', + client_id: ENV["#{self.provider.upcase}_OAUTH_KEY"], + client_secret: ENV["#{self.provider.upcase}_OAUTH_SECRET"], + refresh_token: self.refresh_token + }) + data = JSON.parse(response.body) + self.update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || self.refresh_token) + end + + def self.initialize_or_update_via_omniauth(omniauth) + case omniauth['provider'] + when 'twitter' + find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['nickname']).tap do |service| + service.assign_attributes(token: omniauth['credentials']['token'], secret: omniauth['credentials']['secret']) + end + when 'github' + find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['nickname']).tap do |service| + service.assign_attributes(token: omniauth['credentials']['token']) + end + when '37signals' + find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['name']).tap do |service| + service.assign_attributes(token: omniauth['credentials']['token'], + refresh_token: omniauth['credentials']['refresh_token'], + expires_at: Time.at(omniauth['credentials']['expires_at']), + options: {user_id: omniauth['extra']['accounts'][0]['id']}) + end + else + false + end + end + + private + def endpoint + client_options = "OmniAuth::Strategies::#{OmniAuth::Utils.camelize(self.provider)}".constantize.default_options['client_options'] + URI.join(client_options['site'], client_options['token_url']) + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index c8fc02d9..07aaee86 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -26,6 +26,12 @@ class User < ActiveRecord::Base has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user has_many :logs, :through => :agents, :class_name => "AgentLog" + has_many :services, -> { order("services.name")}, :dependent => :destroy + + + def available_services + Service.where("user_id = #{self.id} or global = true").order("services.name desc") + end # Allow users to login via either email or username. def self.find_first_by_auth_conditions(warden_conditions) diff --git a/app/views/agents/_form.html.erb b/app/views/agents/_form.html.erb index 33abe54f..768159bc 100644 --- a/app/views/agents/_form.html.erb +++ b/app/views/agents/_form.html.erb @@ -25,11 +25,18 @@ <% end %> -
+
<%= f.label :name %> <%= f.text_field :name, :class => 'form-control' %>
+ <% if @agent.try(:oauthable?) %> +
+ <%= f.label :service %> + <%= f.select :service_id, options_for_select(@agent.valid_services(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id),{}, class: 'form-control' %> +
+ <% end %> +
<%= f.label :schedule, :class => 'control-label' %>
diff --git a/app/views/layouts/_navigation.html.erb b/app/views/layouts/_navigation.html.erb index 7108828b..cf51eb3e 100644 --- a/app/views/layouts/_navigation.html.erb +++ b/app/views/layouts/_navigation.html.erb @@ -15,6 +15,7 @@ <%= nav_link "Agents", agents_path %> <%= nav_link "Events", events_path %> <%= nav_link "Credentials", user_credentials_path %> + <%= nav_link "Services", services_path %> <% end %> diff --git a/app/views/services/index.html.erb b/app/views/services/index.html.erb new file mode 100644 index 00000000..38e3b842 --- /dev/null +++ b/app/views/services/index.html.erb @@ -0,0 +1,52 @@ +
+
+
+ +

+ Before you can authenticate with a service, you need to set it up. Have a look at the + <%= link_to 'wiki', 'tobedone', target: :_blank %> + for guidance. +

+

<%= link_to "Authenticate with Twitter", "/auth/twitter" %>

+

<%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %>

+

<%= link_to "Authenticate with Github", "/auth/github" %>

+
+ +
+ + + + + + + + + <% @services.each do |service| %> + + + + + + + <% end %> +
ProviderUsernameGlobal?
<%= service.provider %><%= service.name %><%= service.global ? 'Yes' : 'No' %> +
+ <% if service.global %> + <%= link_to 'Make private', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to remove the access to this service for every user?'}, class: "btn btn-default" %> + <% else %> + <%= link_to 'Make global', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to grant every user access to this service?'}, class: "btn btn-default" %> + <% end %> + <%= link_to 'Delete', service_path(service), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %> +
+
+
+ + <%= paginate @services, :theme => 'twitter-bootstrap-3' %> +
+
+
+ diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000..49c70247 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,5 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + provider :twitter, ENV['TWITTER_OAUTH_KEY'], ENV['TWITTER_OAUTH_SECRET'], authorize_params: {force_login: 'true', use_authorize: 'true'} + provider '37signals', ENV['37SIGNALS_OAUTH_KEY'], ENV['37SIGNALS_OAUTH_SECRET'] + provider :github, ENV['GITHUB_OAUTH_KEY'], ENV['GITHUB_OAUTH_SECRET'] +end diff --git a/config/routes.rb b/config/routes.rb index 76131b0d..f5f0dd1a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -28,6 +28,12 @@ Huginn::Application.routes.draw do resources :user_credentials, :except => :show + resources :services, :only => [:index, :destroy] do + member do + post :toggle_availability + end + end + get "/worker_status" => "worker_status#show" post "/users/:user_id/update_location/:secret" => "user_location_updates#create" @@ -39,6 +45,7 @@ Huginn::Application.routes.draw do # get "/delayed_job" => DelayedJobWeb, :anchor => false devise_for :users, :sign_out_via => [ :post, :delete ] + get '/auth/:provider/callback', to: 'services#callback' get "/about" => "home#about" root :to => "home#index" diff --git a/db/migrate/20140515211100_create_services.rb b/db/migrate/20140515211100_create_services.rb new file mode 100644 index 00000000..5da930ee --- /dev/null +++ b/db/migrate/20140515211100_create_services.rb @@ -0,0 +1,18 @@ +class CreateServices < ActiveRecord::Migration + def change + create_table :services do |t| + t.integer :user_id + t.string :provider + t.string :name + t.text :token + t.text :secret + t.text :refresh_token + t.datetime :expires_at + t.boolean :global, default: false + t.text :options + t.timestamps + end + add_index :services, :user_id + add_index :services, [:user_id, :global] + end +end diff --git a/db/migrate/20140525150040_add_service_id_to_agents.rb b/db/migrate/20140525150040_add_service_id_to_agents.rb new file mode 100644 index 00000000..ebbc6055 --- /dev/null +++ b/db/migrate/20140525150040_add_service_id_to_agents.rb @@ -0,0 +1,5 @@ +class AddServiceIdToAgents < ActiveRecord::Migration + def change + add_column :agents, :service_id, :integer + end +end diff --git a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb new file mode 100644 index 00000000..83941ab8 --- /dev/null +++ b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb @@ -0,0 +1,39 @@ +class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration + def up + agents = Agent.where(type: ['Agents::TwitterUserAgent', 'Agents::TwitterStreamAgent', 'Agents::TwitterPublishAgent']).each do |agent| + service = agent.user.services.create!( + provider: 'twitter', + name: "Migrated '#{agent.name}'", + token: agent.twitter_oauth_token, + secret: agent.twitter_oauth_token_secret + ) + agent.service_id = service.id + agent.save! + end + if agents.length > 0 + puts <<-EOF.strip_heredoc + + Your Twitter agents were successfully migrated. You need to update your .env file and add the following two lines: + + TWITTER_OAUTH_KEY=#{agents.first.twitter_consumer_key} + TWITTER_OAUTH_SECRET=#{agents.first.twitter_consumer_secret} + + + EOF + end + if Agent.where(type: ['Agents::BasecampAgent']).count > 0 + puts <<-EOF.strip_heredoc + + Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate huginn to use it. + Have a look at the if you need help. + + + EOF + end + end + + def down + raise ActiveRecord::IrreversibleMigration, "Cannot revert migration to OAuth services" + end +end + diff --git a/db/schema.rb b/db/schema.rb index a8778316..4a5ff477 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -9,21 +9,24 @@ # from scratch. The latter is a flawed and unsustainable approach (the more migrations # you'll amass, the slower it'll run and the greater likelihood for issues). # -# It's strongly recommended to check this file into your version control system. +# It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(:version => 20140408150825) do +ActiveRecord::Schema.define(version: 20140525150140) do - create_table "agent_logs", :force => true do |t| - t.integer "agent_id", :null => false - t.text "message", :null => false - t.integer "level", :default => 3, :null => false + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "agent_logs", force: true do |t| + t.integer "agent_id", null: false + t.text "message", null: false + t.integer "level", default: 3, null: false t.integer "inbound_event_id" t.integer "outbound_event_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - create_table "agents", :force => true do |t| + create_table "agents", force: true do |t| t.integer "user_id" t.text "options" t.string "type" @@ -33,98 +36,116 @@ ActiveRecord::Schema.define(:version => 20140408150825) do t.datetime "last_check_at" t.datetime "last_receive_at" t.integer "last_checked_event_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false - t.text "memory", :limit => 2147483647 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "memory" t.datetime "last_web_request_at" - t.integer "keep_events_for", :default => 0, :null => false + t.integer "keep_events_for", default: 0, null: false t.datetime "last_event_at" t.datetime "last_error_log_at" - t.boolean "propagate_immediately", :default => false, :null => false - t.boolean "disabled", :default => false, :null => false + t.boolean "propagate_immediately", default: false, null: false + t.boolean "disabled", default: false, null: false + t.integer "service_id" end - add_index "agents", ["schedule"], :name => "index_agents_on_schedule" - add_index "agents", ["type"], :name => "index_agents_on_type" - add_index "agents", ["user_id", "created_at"], :name => "index_agents_on_user_id_and_created_at" + add_index "agents", ["schedule"], name: "index_agents_on_schedule", using: :btree + add_index "agents", ["type"], name: "index_agents_on_type", using: :btree + add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree - create_table "delayed_jobs", :force => true do |t| - t.integer "priority", :default => 0 - t.integer "attempts", :default => 0 - t.text "handler", :limit => 16777215 + create_table "delayed_jobs", force: true do |t| + t.integer "priority", default: 0 + t.integer "attempts", default: 0 + t.text "handler" t.text "last_error" t.datetime "run_at" t.datetime "locked_at" t.datetime "failed_at" t.string "locked_by" t.string "queue" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - add_index "delayed_jobs", ["priority", "run_at"], :name => "delayed_jobs_priority" + add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree - create_table "events", :force => true do |t| + create_table "events", force: true do |t| t.integer "user_id" t.integer "agent_id" - t.decimal "lat", :precision => 15, :scale => 10 - t.decimal "lng", :precision => 15, :scale => 10 - t.text "payload", :limit => 16777215 - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.decimal "lat", precision: 15, scale: 10 + t.decimal "lng", precision: 15, scale: 10 + t.text "payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.datetime "expires_at" end - add_index "events", ["agent_id", "created_at"], :name => "index_events_on_agent_id_and_created_at" - add_index "events", ["expires_at"], :name => "index_events_on_expires_at" - add_index "events", ["user_id", "created_at"], :name => "index_events_on_user_id_and_created_at" + add_index "events", ["agent_id", "created_at"], name: "index_events_on_agent_id_and_created_at", using: :btree + add_index "events", ["expires_at"], name: "index_events_on_expires_at", using: :btree + add_index "events", ["user_id", "created_at"], name: "index_events_on_user_id_and_created_at", using: :btree - create_table "links", :force => true do |t| + create_table "links", force: true do |t| t.integer "source_id" t.integer "receiver_id" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false - t.integer "event_id_at_creation", :default => 0, :null => false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "event_id_at_creation", default: 0, null: false end - add_index "links", ["receiver_id", "source_id"], :name => "index_links_on_receiver_id_and_source_id" - add_index "links", ["source_id", "receiver_id"], :name => "index_links_on_source_id_and_receiver_id" + add_index "links", ["receiver_id", "source_id"], name: "index_links_on_receiver_id_and_source_id", using: :btree + add_index "links", ["source_id", "receiver_id"], name: "index_links_on_source_id_and_receiver_id", using: :btree - create_table "user_credentials", :force => true do |t| - t.integer "user_id", :null => false - t.string "credential_name", :null => false - t.text "credential_value", :null => false - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false - t.string "mode", :default => "text", :null => false + create_table "services", force: true do |t| + t.integer "user_id" + t.string "provider" + t.string "name" + t.text "token" + t.text "secret" + t.text "refresh_token" + t.datetime "expires_at" + t.boolean "global", default: false + t.text "options" + t.datetime "created_at" + t.datetime "updated_at" end - add_index "user_credentials", ["user_id", "credential_name"], :name => "index_user_credentials_on_user_id_and_credential_name", :unique => true + add_index "services", ["user_id", "global"], name: "index_accounts_on_user_id_and_global", using: :btree + add_index "services", ["user_id"], name: "index_accounts_on_user_id", using: :btree - create_table "users", :force => true do |t| - t.string "email", :default => "", :null => false - t.string "encrypted_password", :default => "", :null => false + create_table "user_credentials", force: true do |t| + t.integer "user_id", null: false + t.string "credential_name", null: false + t.text "credential_value", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "mode", default: "text", null: false + end + + add_index "user_credentials", ["user_id", "credential_name"], name: "index_user_credentials_on_user_id_and_credential_name", unique: true, using: :btree + + create_table "users", force: true do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false t.string "reset_password_token" t.datetime "reset_password_sent_at" t.datetime "remember_created_at" - t.integer "sign_in_count", :default => 0 + t.integer "sign_in_count", default: 0 t.datetime "current_sign_in_at" t.datetime "last_sign_in_at" t.string "current_sign_in_ip" t.string "last_sign_in_ip" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false - t.boolean "admin", :default => false, :null => false - t.integer "failed_attempts", :default => 0 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "admin", default: false, null: false + t.integer "failed_attempts", default: 0 t.string "unlock_token" t.datetime "locked_at" - t.string "username", :null => false - t.string "invitation_code", :null => false + t.string "username", null: false + t.string "invitation_code", null: false end - add_index "users", ["email"], :name => "index_users_on_email", :unique => true - add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true - add_index "users", ["unlock_token"], :name => "index_users_on_unlock_token", :unique => true - add_index "users", ["username"], :name => "index_users_on_username", :unique => true + add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree + add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree + add_index "users", ["unlock_token"], name: "index_users_on_unlock_token", unique: true, using: :btree + add_index "users", ["username"], name: "index_users_on_username", unique: true, using: :btree end diff --git a/spec/controllers/services_controller_spec.rb b/spec/controllers/services_controller_spec.rb new file mode 100644 index 00000000..cee099db --- /dev/null +++ b/spec/controllers/services_controller_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe ServicesController do + before do + sign_in users(:bob) + OmniAuth.config.test_mode = true + request.env["omniauth.auth"] = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json'))) + end + + describe "GET index" do + it "only returns sevices of the current user" do + get :index + assigns(:services).all? {|i| i.user.should == users(:bob) }.should be_true + end + end + + describe "POST toggle_availability" do + it "should work for service of the user" do + post :toggle_availability, :id => services(:generic).to_param + assigns(:service).should eq(services(:generic)) + redirect_to(services_path) + end + + it "should not work for a service of another user" do + lambda { + post :toggle_availability, :id => services(:global).to_param + }.should raise_error(ActiveRecord::RecordNotFound) + end + end + + describe "DELETE destroy" do + it "destroys only services owned by the current user" do + expect { + delete :destroy, :id => services(:generic).to_param + }.to change(Service, :count).by(-1) + + lambda { + delete :destroy, :id => services(:global).to_param + }.should raise_error(ActiveRecord::RecordNotFound) + end + end + + describe "accepting a callback url" do + it "should update the users credentials" do + expect { + get :callback, provider: 'twitter' + }.to change { users(:bob).services.count }.by(1) + end + + it "should not work with an unknown provider" do + request.env["omniauth.auth"]['provider'] = 'unknown' + expect { + get :callback, provider: 'unknown' + }.to change { users(:bob).services.count }.by(0) + end + end +end diff --git a/spec/data_fixtures/services/37signals.json b/spec/data_fixtures/services/37signals.json new file mode 100644 index 00000000..13fb9b17 --- /dev/null +++ b/spec/data_fixtures/services/37signals.json @@ -0,0 +1,43 @@ +{ + "provider": "37signals", + "uid": 12345, + "info": { + "email": "basecamp@none.de", + "first_name": "Dominik", + "last_name": "Sander", + "name": "Dominik Sander" + }, + "credentials": { + "token": "abcde", + "refresh_token": "fghrefresh", + "expires_at": 1401554352, + "expires": true + }, + "extra": { + "accounts": [ + { + "product": "bcx", + "name": "Dominik Sander's Basecamp", + "id": 12345, + "href": "https://basecamp.com/12345/api/v1" + } + ], + "raw_info": { + "expires_at": "2014-05-31T16:39:12Z", + "identity": { + "first_name": "Dominik", + "last_name": "Sander", + "email_address": "basecamp@none.de", + "id": 12345 + }, + "accounts": [ + { + "product": "bcx", + "name": "Dominik Sander's Basecamp", + "id": 12345, + "href": "https://basecamp.com/12345/api/v1" + } + ] + } + } +} \ No newline at end of file diff --git a/spec/data_fixtures/services/github.json b/spec/data_fixtures/services/github.json new file mode 100644 index 00000000..ef0d09c8 --- /dev/null +++ b/spec/data_fixtures/services/github.json @@ -0,0 +1,52 @@ +{ + "provider": "github", + "uid": "12345", + "info": { + "nickname": "dsander", + "email": null, + "name": "Dominik Sander", + "image": "https://avatars.githubusercontent.com/u/12345?", + "urls": { + "GitHub": "https://github.com/dsander", + "Blog": "http://www.dsander.de" + } + }, + "credentials": { + "token": "agithubtoken", + "expires": false + }, + "extra": { + "raw_info": { + "login": "dsander", + "id": 12345, + "avatar_url": "https://avatars.githubusercontent.com/u/12345?", + "gravatar_id": "fsdfsdf", + "url": "https://api.github.com/users/dsander", + "html_url": "https://github.com/dsander", + "followers_url": "https://api.github.com/users/dsander/followers", + "following_url": "https://api.github.com/users/dsander/following{/other_user}", + "gists_url": "https://api.github.com/users/dsander/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dsander/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dsander/subscriptions", + "organizations_url": "https://api.github.com/users/dsander/orgs", + "repos_url": "https://api.github.com/users/dsander/repos", + "events_url": "https://api.github.com/users/dsander/events{/privacy}", + "received_events_url": "https://api.github.com/users/dsander/received_events", + "type": "User", + "site_admin": false, + "name": "Dominik Sander", + "company": null, + "blog": "http://www.url.de", + "location": null, + "email": null, + "hireable": false, + "bio": null, + "public_repos": 29, + "public_gists": 2, + "followers": 21, + "following": 9, + "created_at": "2008-08-17T18:17:50Z", + "updated_at": "2014-05-19T09:30:08Z" + } + } +} \ No newline at end of file diff --git a/spec/data_fixtures/services/twitter.json b/spec/data_fixtures/services/twitter.json new file mode 100644 index 00000000..5bbfa724 --- /dev/null +++ b/spec/data_fixtures/services/twitter.json @@ -0,0 +1,66 @@ +{ + "provider": "twitter", + "uid": "123456", + "info": { + "nickname": "johnqpublic", + "name": "John Q Public", + "location": "Anytown, USA", + "image": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", + "description": "a very normal guy.", + "urls": { + "Website": null, + "Twitter": "https://twitter.com/johnqpublic" + } + }, + "credentials": { + "token": "a1b2c3d4...", + "secret": "abcdef1234" + }, + "extra": { + "access_token": "", + "raw_info": { + "name": "John Q Public", + "listed_count": 0, + "profile_sidebar_border_color": "181A1E", + "url": null, + "lang": "en", + "statuses_count": 129, + "profile_image_url": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", + "profile_background_image_url_https": "https://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif", + "location": "Anytown, USA", + "time_zone": "Chicago", + "follow_request_sent": false, + "id": 123456, + "profile_background_tile": true, + "profile_sidebar_fill_color": "666666", + "followers_count": 1, + "default_profile_image": false, + "screen_name": "", + "following": false, + "utc_offset": -3600, + "verified": false, + "favourites_count": 0, + "profile_background_color": "1A1B1F", + "is_translator": false, + "friends_count": 1, + "notifications": false, + "geo_enabled": true, + "profile_background_image_url": "http://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif", + "protected": false, + "description": "a very normal guy.", + "profile_link_color": "2FC2EF", + "created_at": "Thu Jul 4 00:00:00 +0000 2013", + "id_str": "123456", + "profile_image_url_https": "https://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", + "default_profile": false, + "profile_use_background_image": false, + "entities": { + "description": { + "urls": [] + } + }, + "profile_text_color": "666666", + "contributors_enabled": false + } + } +} \ No newline at end of file diff --git a/spec/fixtures/services.yml b/spec/fixtures/services.yml new file mode 100644 index 00000000..731d9b18 --- /dev/null +++ b/spec/fixtures/services.yml @@ -0,0 +1,17 @@ +generic: + token: 1234token + secret: 56789secret + refresh_token: refresh12345 + provider: testprovider + name: test + expires_at: <%= Time.parse("2015-01-01 00:00:00") %> + options: <%= { user_id: 12345 }.to_yaml.inspect %> + user: bob +global: + token: 1234token + provider: testprovider + name: test + expires_at: <%= Time.parse("2015-01-01 00:00:00") %> + options: <%= { user_id: 12345 }.to_yaml.inspect %> + user: jane + global: true \ No newline at end of file diff --git a/spec/models/agents/basecamp_agent_spec.rb b/spec/models/agents/basecamp_agent_spec.rb index 54c25339..c9a0bd2d 100644 --- a/spec/models/agents/basecamp_agent_spec.rb +++ b/spec/models/agents/basecamp_agent_spec.rb @@ -1,17 +1,16 @@ require 'spec_helper' +require 'models/concerns/oauthable' describe Agents::BasecampAgent do + it_behaves_like Oauthable + before(:each) do stub_request(:get, /json$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"}) stub_request(:get, /Z$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"}) - @valid_params = { - :username => "user", - :password => "pass", - :user_id => 12345, - :project_id => 6789, - } + @valid_params = { :project_id => 6789 } @checker = Agents::BasecampAgent.new(:name => "somename", :options => @valid_params) + @checker.service = services(:generic) @checker.user = users(:jane) @checker.save! end @@ -21,21 +20,6 @@ describe Agents::BasecampAgent do @checker.should be_valid end - it "should require the basecamp username" do - @checker.options['username'] = nil - @checker.should_not be_valid - end - - it "should require the basecamp password" do - @checker.options['password'] = nil - @checker.should_not be_valid - end - - it "should require the basecamp user_id" do - @checker.options['user_id'] = nil - @checker.should_not be_valid - end - it "should require the basecamp project_id" do @checker.options['project_id'] = nil @checker.should_not be_valid @@ -45,7 +29,7 @@ describe Agents::BasecampAgent do describe "helpers" do it "should generate a correct request options hash" do - @checker.send(:request_options).should == {:basic_auth=>{:username=>"user", :password=>"pass"}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}} + @checker.send(:request_options).should == {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => 'Bearer "1234token"'}} end it "should generate the currect request url" do diff --git a/spec/models/agents/twitter_publish_agent_spec.rb b/spec/models/agents/twitter_publish_agent_spec.rb index 6d0a4584..aee73bd2 100644 --- a/spec/models/agents/twitter_publish_agent_spec.rb +++ b/spec/models/agents/twitter_publish_agent_spec.rb @@ -13,6 +13,7 @@ describe Agents::TwitterPublishAgent do } @checker = Agents::TwitterPublishAgent.new(:name => "HuginnBot", :options => @opts) + @checker.service = services(:generic) @checker.user = users(:bob) @checker.save! diff --git a/spec/models/agents/twitter_stream_agent_spec.rb b/spec/models/agents/twitter_stream_agent_spec.rb index 816decb9..eb56f200 100644 --- a/spec/models/agents/twitter_stream_agent_spec.rb +++ b/spec/models/agents/twitter_stream_agent_spec.rb @@ -13,6 +13,7 @@ describe Agents::TwitterStreamAgent do } @agent = Agents::TwitterStreamAgent.new(:name => "HuginnBot", :options => @opts) + @agent.service = services(:generic) @agent.user = users(:bob) @agent.save! end diff --git a/spec/models/agents/twitter_user_agent_spec.rb b/spec/models/agents/twitter_user_agent_spec.rb index 8739e853..0acdd787 100644 --- a/spec/models/agents/twitter_user_agent_spec.rb +++ b/spec/models/agents/twitter_user_agent_spec.rb @@ -15,6 +15,7 @@ describe Agents::TwitterUserAgent do } @checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => @opts) + @checker.service = services(:generic) @checker.user = users(:bob) @checker.save! end diff --git a/spec/models/concerns/oauthable.rb b/spec/models/concerns/oauthable.rb new file mode 100644 index 00000000..2d4f6f50 --- /dev/null +++ b/spec/models/concerns/oauthable.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +module Agents + class OauthableTestAgent < Agent + include Oauthable + end +end + +shared_examples_for Oauthable do + before(:each) do + @agent = described_class.new(:name => "somename") + @agent.user = users(:jane) + end + + it "should be oauthable" do + @agent.oauthable?.should == true + end + + describe "valid_services" do + it "should return all available services without specifying valid_oauth_providers" do + @agent = Agents::OauthableTestAgent.new + @agent.valid_services(users(:bob)).collect(&:id).sort.should == [services(:generic), services(:global)].collect(&:id).sort + end + + it "should filter the services based on the agent defaults" do + @agent.valid_services(users(:bob)).to_a.should == Service.where(provider: @agent.valid_oauth_providers) + end + end +end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb new file mode 100644 index 00000000..10a4072b --- /dev/null +++ b/spec/models/service_spec.rb @@ -0,0 +1,100 @@ +require 'spec_helper' + +describe Service do + before(:each) do + @user = users(:bob) + end + + it "should toggle the global flag" do + @service = services(:generic) + @service.global.should == false + @service.toggle_availability! + @service.global.should == true + @service.toggle_availability! + @service.global.should == false + end + + describe "preparing for a request" do + before(:each) do + @service = services(:generic) + end + + it "should not update the token if the token never expires" do + @service.expires_at = nil + @service.prepare_request.should == nil + end + + it "should not update the token if the token is still valid" do + @service.expires_at = Time.now + 1.hour + @service.prepare_request.should == nil + end + + it "should call refresh_token! if the token expired" do + stub(@service).refresh_token! { @service } + @service.expires_at = Time.now - 1.hour + @service.prepare_request.should == @service + end + end + + describe "updating the access token" do + before(:each) do + @service = services(:generic) + end + + it "should return the correct endpoint" do + @service.provider = '37signals' + @service.send(:endpoint).to_s.should == "https://launchpad.37signals.com/authorization/token" + end + + it "should update the token" do + stub_request(:post, "https://launchpad.37signals.com/authorization/token?client_id=TESTKEY&client_secret=TESTSECRET&refresh_token=refreshtokentest&type=refresh"). + to_return(:status => 200, :body => '{"expires_in":1209600,"access_token": "NEWTOKEN"}', :headers => {}) + @service.provider = '37signals' + ENV['37SIGNALS_OAUTH_KEY'] = 'TESTKEY' + ENV['37SIGNALS_OAUTH_SECRET'] = 'TESTSECRET' + @service.refresh_token = 'refreshtokentest' + @service.refresh_token! + @service.token.should == 'NEWTOKEN' + end + end + + describe "creating services via omniauth" do + it "should work with twitter services" do + twitter = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json'))) + expect { + service = @user.services.initialize_or_update_via_omniauth(twitter) + service.save! + }.to change { @user.services.count }.by(1) + service = @user.services.first + service.name.should == 'johnqpublic' + service.provider.should == 'twitter' + service.token.should == 'a1b2c3d4...' + service.secret.should == 'abcdef1234' + end + it "should work with 37signals services" do + signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/37signals.json'))) + expect { + service = @user.services.initialize_or_update_via_omniauth(signals) + service.save! + }.to change { @user.services.count }.by(1) + service = @user.services.first + service.provider.should == '37signals' + service.name.should == 'Dominik Sander' + service.token.should == 'abcde' + service.refresh_token.should == 'fghrefresh' + service.options[:user_id].should == 12345 + service.expires_at = Time.at(1401554352) + end + it "should work with github services" do + signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/github.json'))) + expect { + service = @user.services.initialize_or_update_via_omniauth(signals) + service.save! + }.to change { @user.services.count }.by(1) + service = @user.services.first + service.provider.should == 'github' + service.name.should == 'dsander' + service.token.should == 'agithubtoken' + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5be67d89..f4fa126e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -21,6 +21,8 @@ WebMock.disable_net_connect! # in spec/support/ and its subdirectories. Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} +ActiveRecord::Migration.maintain_test_schema! + RSpec.configure do |config| config.mock_with :rr From 4ffd9ebb915a305a50b442de183d3c75df8f9978 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sat, 31 May 2014 10:44:59 +0200 Subject: [PATCH 02/10] Update new agent form to include the service dropdown When creating a new agent the service dropdown needs to be updated after switing the agent type. Rendering the whole form and cherry picking just the needed services dropdown seemed the easierst solution. --- app/assets/javascripts/application.js.coffee.erb | 2 ++ app/controllers/agents_controller.rb | 13 +++++++------ app/views/agents/_form.html.erb | 14 ++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee.erb b/app/assets/javascripts/application.js.coffee.erb index 482a363d..cd1cfba3 100644 --- a/app/assets/javascripts/application.js.coffee.erb +++ b/app/assets/javascripts/application.js.coffee.erb @@ -155,6 +155,8 @@ $(document).ready -> $(".description").html(json.description_html) if json.description_html? + $('.oauthable-form').html($(json.form).find('.oauthable-form').html()) if json.form? + if $("#agent_options").hasClass("showing-default") || $("#agent_options").val().match(/\A\s*(\{\s*\}|)\s*\Z/g) window.jsonEditor.json = json.options window.jsonEditor.rebuild() diff --git a/app/controllers/agents_controller.rb b/app/controllers/agents_controller.rb index 2c158006..ae0712a7 100644 --- a/app/controllers/agents_controller.rb +++ b/app/controllers/agents_controller.rb @@ -31,13 +31,14 @@ class AgentsController < ApplicationController end def type_details - agent = Agent.build_for_type(params[:type], current_user, {}) + @agent = Agent.build_for_type(params[:type], current_user, {}) render :json => { - :can_be_scheduled => agent.can_be_scheduled?, - :can_receive_events => agent.can_receive_events?, - :can_create_events => agent.can_create_events?, - :options => agent.default_options, - :description_html => agent.html_description + :can_be_scheduled => @agent.can_be_scheduled?, + :can_receive_events => @agent.can_receive_events?, + :can_create_events => @agent.can_create_events?, + :options => @agent.default_options, + :description_html => @agent.html_description, + :form => render_to_string(partial: 'form') } end diff --git a/app/views/agents/_form.html.erb b/app/views/agents/_form.html.erb index 768159bc..03ff4c81 100644 --- a/app/views/agents/_form.html.erb +++ b/app/views/agents/_form.html.erb @@ -30,12 +30,14 @@ <%= f.text_field :name, :class => 'form-control' %>
- <% if @agent.try(:oauthable?) %> -
- <%= f.label :service %> - <%= f.select :service_id, options_for_select(@agent.valid_services(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id),{}, class: 'form-control' %> -
- <% end %> +
+ <% if @agent.try(:oauthable?) %> +
+ <%= f.label :service %> + <%= f.select :service_id, options_for_select(@agent.valid_services(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id),{}, class: 'form-control' %> +
+ <% end %> +
<%= f.label :schedule, :class => 'control-label' %> From 70b03640dadb05707788c512966b7923bd52ac0f Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sun, 8 Jun 2014 10:30:45 +0200 Subject: [PATCH 03/10] Added documentation about how to set up OAuth applications --- app/models/agents/basecamp_agent.rb | 2 ++ app/models/agents/twitter_publish_agent.rb | 6 +----- app/models/agents/twitter_stream_agent.rb | 6 +----- app/models/agents/twitter_user_agent.rb | 6 +----- app/views/services/index.html.erb | 2 +- 5 files changed, 6 insertions(+), 16 deletions(-) diff --git a/app/models/agents/basecamp_agent.rb b/app/models/agents/basecamp_agent.rb index 83cbd674..f98be409 100644 --- a/app/models/agents/basecamp_agent.rb +++ b/app/models/agents/basecamp_agent.rb @@ -8,6 +8,8 @@ module Agents description <<-MD The BasecampAgent checks a Basecamp project for new Events + To be able to use this Agent you need to authenticate with 37signals in the [Services](/services) section first. + You need to provide the `project_id` of the project you want to monitor. If you have your Basecamp project opened in your browser you can find the user_id and project_id as follows: diff --git a/app/models/agents/twitter_publish_agent.rb b/app/models/agents/twitter_publish_agent.rb index d0872a29..2a698d0e 100644 --- a/app/models/agents/twitter_publish_agent.rb +++ b/app/models/agents/twitter_publish_agent.rb @@ -10,11 +10,7 @@ module Agents description <<-MD The TwitterPublishAgent publishes tweets from the events it receives. - Twitter credentials must be supplied as either [credentials](/user_credentials) called - `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`, - or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`. - - To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token). + To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first. You must also specify a `message` parameter, you can use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the message. diff --git a/app/models/agents/twitter_stream_agent.rb b/app/models/agents/twitter_stream_agent.rb index 1321e577..d0083938 100644 --- a/app/models/agents/twitter_stream_agent.rb +++ b/app/models/agents/twitter_stream_agent.rb @@ -10,11 +10,7 @@ module Agents To follow the Twitter stream, provide an array of `filters`. Multiple words in a filter must all show up in a tweet, but are independent of order. If you provide an array instead of a filter, the first entry will be considered primary and any additional values will be treated as aliases. - Twitter credentials must be supplied as either [credentials](/user_credentials) called - `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`, - or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`. - - To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token). + To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first. Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent. diff --git a/app/models/agents/twitter_user_agent.rb b/app/models/agents/twitter_user_agent.rb index 79f87326..16019ed6 100644 --- a/app/models/agents/twitter_user_agent.rb +++ b/app/models/agents/twitter_user_agent.rb @@ -9,11 +9,7 @@ module Agents description <<-MD The TwitterUserAgent follows the timeline of a specified Twitter user. - Twitter credentials must be supplied as either [credentials](/user_credentials) called - `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`, - or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`. - - To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token). + To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first. You must also provide the `username` of the Twitter user to monitor. diff --git a/app/views/services/index.html.erb b/app/views/services/index.html.erb index 38e3b842..857ddb9f 100644 --- a/app/views/services/index.html.erb +++ b/app/views/services/index.html.erb @@ -8,7 +8,7 @@

Before you can authenticate with a service, you need to set it up. Have a look at the - <%= link_to 'wiki', 'tobedone', target: :_blank %> + <%= link_to 'wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: :_blank %> for guidance.

<%= link_to "Authenticate with Twitter", "/auth/twitter" %>

From 4930e1ed59bd0cff3f9b4116c9ef76ab13f387c8 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Wed, 11 Jun 2014 21:36:45 +0200 Subject: [PATCH 04/10] User#available_services make sure to escape the parameters, added missing link to the wiki --- app/models/user.rb | 2 +- .../20140525150140_migrate_agents_to_service_authentication.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 07aaee86..c1f7044e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -30,7 +30,7 @@ class User < ActiveRecord::Base def available_services - Service.where("user_id = #{self.id} or global = true").order("services.name desc") + Service.where("user_id = ? or global = true", self.id).order("services.name desc") end # Allow users to login via either email or username. diff --git a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb index 83941ab8..1104ef3a 100644 --- a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb +++ b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb @@ -25,7 +25,7 @@ class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration puts <<-EOF.strip_heredoc Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate huginn to use it. - Have a look at the if you need help. + Have a look at the wiki (https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications) if you need help. EOF From db968e6f4fe20ccbe7b446f42a5b0d090c1d0892 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sat, 14 Jun 2014 11:21:40 +0200 Subject: [PATCH 05/10] Fixed services migration, disabled agents after deletion of a service --- app/models/service.rb | 11 +++++++++ ...igrate_agents_to_service_authentication.rb | 24 +++++++++++++++---- spec/fixtures/agents.yml | 5 ++++ spec/models/service_spec.rb | 9 +++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/app/models/service.rb b/app/models/service.rb index a2ceedfc..5f859d1a 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -4,9 +4,20 @@ class Service < ActiveRecord::Base serialize :options, Hash belongs_to :user + has_many :agents validates_presence_of :user_id, :provider, :name, :token + before_destroy :disable_agents + + def disable_agents + self.agents.each do |agent| + agent.service_id = nil + agent.disabled = true + agent.save!(validate: false) + end + end + def toggle_availability! self.global = !self.global self.save! diff --git a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb index 1104ef3a..11459fe4 100644 --- a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb +++ b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb @@ -1,11 +1,27 @@ class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration + def twitter_consumer_key(agent) + agent.options['consumer_key'].presence || agent.credential('twitter_consumer_key') + end + + def twitter_consumer_secret(agent) + agent.options['consumer_secret'].presence || agent.credential('twitter_consumer_secret') + end + + def twitter_oauth_token(agent) + agent.options['oauth_token'].presence || agent.options['access_key'].presence || agent.credential('twitter_oauth_token') + end + + def twitter_oauth_token_secret(agent) + agent.options['oauth_token_secret'].presence || agent.options['access_secret'].presence || agent.credential('twitter_oauth_token_secret') + end + def up agents = Agent.where(type: ['Agents::TwitterUserAgent', 'Agents::TwitterStreamAgent', 'Agents::TwitterPublishAgent']).each do |agent| service = agent.user.services.create!( provider: 'twitter', name: "Migrated '#{agent.name}'", - token: agent.twitter_oauth_token, - secret: agent.twitter_oauth_token_secret + token: twitter_oauth_token(agent), + secret: twitter_oauth_token_secret(agent) ) agent.service_id = service.id agent.save! @@ -15,8 +31,8 @@ class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration Your Twitter agents were successfully migrated. You need to update your .env file and add the following two lines: - TWITTER_OAUTH_KEY=#{agents.first.twitter_consumer_key} - TWITTER_OAUTH_SECRET=#{agents.first.twitter_consumer_secret} + TWITTER_OAUTH_KEY=#{twitter_consumer_key(agents.first)} + TWITTER_OAUTH_SECRET=#{twitter_consumer_secret(agents.first)} EOF diff --git a/spec/fixtures/agents.yml b/spec/fixtures/agents.yml index d07e0ccb..0b733655 100644 --- a/spec/fixtures/agents.yml +++ b/spec/fixtures/agents.yml @@ -101,3 +101,8 @@ bob_manual_event_agent: type: Agents::ManualEventAgent user: bob name: "Bob's event testing agent" + +bob_basecamp_agent: + type: Agents::BasecampAgent + user: bob + service: generic \ No newline at end of file diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 10a4072b..f874f887 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -14,6 +14,15 @@ describe Service do @service.global.should == false end + it "disables all agents before beeing destroyed" do + agent = agents(:bob_basecamp_agent) + service = agent.service + service.destroy + agent.reload + agent.service_id.should be_nil + agent.disabled.should be_true + end + describe "preparing for a request" do before(:each) do @service = services(:generic) From b0899194907789431b570a1983ff8b019ed53ba2 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sat, 14 Jun 2014 20:00:31 +0200 Subject: [PATCH 06/10] Skip validations in the oauth migration Also enforced the presence of needed attributes of the services table on the database level --- app/concerns/oauthable.rb | 1 - db/migrate/20140515211100_create_services.rb | 8 ++++---- ...0525150140_migrate_agents_to_service_authentication.rb | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/concerns/oauthable.rb b/app/concerns/oauthable.rb index 57f523dc..743cf53f 100644 --- a/app/concerns/oauthable.rb +++ b/app/concerns/oauthable.rb @@ -4,7 +4,6 @@ module Oauthable included do |base| attr_accessible :service_id validates_presence_of :service_id - base.extend ClassMethods self.class_variable_set(:@@valid_oauth_providers, :all) end diff --git a/db/migrate/20140515211100_create_services.rb b/db/migrate/20140515211100_create_services.rb index 5da930ee..7499bb00 100644 --- a/db/migrate/20140515211100_create_services.rb +++ b/db/migrate/20140515211100_create_services.rb @@ -1,10 +1,10 @@ class CreateServices < ActiveRecord::Migration def change create_table :services do |t| - t.integer :user_id - t.string :provider - t.string :name - t.text :token + t.integer :user_id, null: false + t.string :provider, null: false + t.string :name, null: false + t.text :token, null: false t.text :secret t.text :refresh_token t.datetime :expires_at diff --git a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb index 11459fe4..cb9fc219 100644 --- a/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb +++ b/db/migrate/20140525150140_migrate_agents_to_service_authentication.rb @@ -24,7 +24,7 @@ class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration secret: twitter_oauth_token_secret(agent) ) agent.service_id = service.id - agent.save! + agent.save!(validate: false) end if agents.length > 0 puts <<-EOF.strip_heredoc From f705963e09864c3eb22856519971ce01ed6cd3da Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sat, 14 Jun 2014 20:38:11 +0200 Subject: [PATCH 07/10] OAuthable concern now uses class instance variables --- app/concerns/oauthable.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/concerns/oauthable.rb b/app/concerns/oauthable.rb index 743cf53f..ca738984 100644 --- a/app/concerns/oauthable.rb +++ b/app/concerns/oauthable.rb @@ -2,9 +2,9 @@ module Oauthable extend ActiveSupport::Concern included do |base| + @valid_oauth_providers = :all attr_accessible :service_id validates_presence_of :service_id - self.class_variable_set(:@@valid_oauth_providers, :all) end def oauthable? @@ -20,12 +20,13 @@ module Oauthable end def valid_oauth_providers - self.class.class_variable_get(:@@valid_oauth_providers) + self.class.valid_oauth_providers end module ClassMethods def valid_oauth_providers(*providers) - self.class_variable_set(:@@valid_oauth_providers, providers) + return @valid_oauth_providers if providers == [] + @valid_oauth_providers = providers end end end From 2bec1e9c823e4973158da0d4d58c1a6b35b2a360 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sat, 14 Jun 2014 20:52:35 +0200 Subject: [PATCH 08/10] Fixed the specs --- spec/models/agents/twitter_user_agent_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/models/agents/twitter_user_agent_spec.rb b/spec/models/agents/twitter_user_agent_spec.rb index 2851f29c..f673c52d 100644 --- a/spec/models/agents/twitter_user_agent_spec.rb +++ b/spec/models/agents/twitter_user_agent_spec.rb @@ -32,6 +32,7 @@ describe Agents::TwitterUserAgent do opts = @opts.merge({ :starting_at => "Jan 01 00:00:01 +0000 2999", }) checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => opts) + checker.service = services(:generic) checker.user = users(:bob) checker.save! From a846154c7d90778a44513c536433ba0e0c3b005e Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Sun, 3 Aug 2014 18:26:56 +0200 Subject: [PATCH 09/10] Import scenarios with agents which need a service --- app/models/scenario_import.rb | 23 ++++++-- app/views/scenario_imports/_step_two.html.erb | 10 ++++ db/schema.rb | 2 +- spec/models/scenario_import_spec.rb | 55 +++++++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/app/models/scenario_import.rb b/app/models/scenario_import.rb index dd853770..ab723d66 100644 --- a/app/models/scenario_import.rb +++ b/app/models/scenario_import.rb @@ -76,17 +76,19 @@ class ScenarioImport agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present? agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present? agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true" + agent.service_id = agent_diff.service_id.updated if agent_diff.service_id.present? unless agent.save success = false errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}") end agent end - - links.each do |link| - receiver = created_agents[link['receiver']] - source = created_agents[link['source']] - receiver.sources << source unless receiver.sources.include?(source) + if success + links.each do |link| + receiver = created_agents[link['receiver']] + source = created_agents[link['source']] + receiver.sources << source unless receiver.sources.include?(source) + end end end @@ -149,6 +151,9 @@ class ScenarioImport errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.") end end + if agent_diff.requires_service? && merges.present? && merges[index.to_s].present? && merges[index.to_s]['service_id'].present? + agent_diff.service_id = AgentDiff::FieldDiff.new(merges[index.to_s]['service_id'].to_i) + end agent_diff end end @@ -192,6 +197,10 @@ class ScenarioImport @requires_merge end + def requires_service? + !!agent_instance.try(:oauthable?) + end + def store!(agent_data) self.type = FieldDiff.new(agent_data["type"].split("::").pop) self.options = FieldDiff.new(agent_data['options'] || {}) @@ -252,5 +261,9 @@ class ScenarioImport key.gsub(/[^a-zA-Z0-9_-]/, '') end end + + def agent_instance + "Agents::#{self.type.updated}".constantize.new + end end end diff --git a/app/views/scenario_imports/_step_two.html.erb b/app/views/scenario_imports/_step_two.html.erb index f681b961..86885149 100644 --- a/app/views/scenario_imports/_step_two.html.erb +++ b/app/views/scenario_imports/_step_two.html.erb @@ -120,6 +120,16 @@
<% end %>
+ <% if agent_diff.requires_service? %> +
+
+
+ <%= label_tag "scenario_import[merges][#{index}][service_id]", 'Service' %> + <%= select_tag "scenario_import[merges][#{index}][service_id]", options_for_select(agent_diff.agent_instance.valid_services(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, agent_diff.service_id.try(:current)), class: 'form-control' %> +
+
+
+ <% end %> <% end %> diff --git a/db/schema.rb b/db/schema.rb index ada1cab9..fbfe18d1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20140605032822) do +ActiveRecord::Schema.define(version: 20140723110551) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/spec/models/scenario_import_spec.rb b/spec/models/scenario_import_spec.rb index 836083f0..db211bec 100644 --- a/spec/models/scenario_import_spec.rb +++ b/spec/models/scenario_import_spec.rb @@ -45,6 +45,18 @@ describe ScenarioImport do :options => trigger_agent_options } end + let(:valid_parsed_basecamp_agent_data) do + { + :type => "Agents::BasecampAgent", + :name => "Basecamp test", + :schedule => "every_2m", + :keep_events_for => 0, + :propagate_immediately => true, + :disabled => false, + :guid => "a-basecamp-agent", + :options => {project_id: 12345} + } + end let(:valid_parsed_data) do { :name => name, @@ -407,5 +419,48 @@ describe ScenarioImport do end end end + + context "agents which require a service" do + let(:valid_parsed_services) do + data = valid_parsed_data + data[:agents] = [valid_parsed_basecamp_agent_data, + valid_parsed_trigger_agent_data] + data + end + + let(:valid_parsed_services_data) { valid_parsed_services.to_json } + + let(:services_scenario_import) { + _import = ScenarioImport.new(:data => valid_parsed_services_data) + _import.set_user users(:bob) + _import + } + + describe "#generate_diff" do + it "should check if the agent requires a service" do + agent_diffs = services_scenario_import.agent_diffs + basecamp_agent_diff = agent_diffs[0] + basecamp_agent_diff.requires_service?.should == true + end + + it "should add an error when no service is selected" do + services_scenario_import.import.should == false + services_scenario_import.errors[:base].length.should == 1 + end + end + + describe "#import" do + it "should import" do + services_scenario_import.merges = { + "0" => { + "service_id" => "0", + } + } + lambda { + services_scenario_import.import.should == true + }.should change { users(:bob).agents.count }.by(2) + end + end + end end end \ No newline at end of file From 1daf1ddd2aeee26243a69e49f5056eefe8cd6962 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Mon, 4 Aug 2014 20:46:21 +0200 Subject: [PATCH 10/10] Renamed 37signals environmental variable to not start with numbers --- .env.example | 4 ++-- config/initializers/omniauth.rb | 2 +- spec/models/service_spec.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 7bc2f79c..87f17ef4 100644 --- a/.env.example +++ b/.env.example @@ -76,8 +76,8 @@ AGENT_LOG_LENGTH=200 TWITTER_OAUTH_KEY= TWITTER_OAUTH_SECRET= -37SIGNALS_OAUTH_KEY= -37SIGNALS_OAUTH_SECRET= +THIRTY_SEVEN_SIGNALS_OAUTH_KEY= +THIRTY_SEVEN_SIGNALS_OAUTH_SECRET= GITHUB_OAUTH_KEY= GITHUB_OAUTH_SECRET= diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 49c70247..2143a089 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,5 +1,5 @@ Rails.application.config.middleware.use OmniAuth::Builder do provider :twitter, ENV['TWITTER_OAUTH_KEY'], ENV['TWITTER_OAUTH_SECRET'], authorize_params: {force_login: 'true', use_authorize: 'true'} - provider '37signals', ENV['37SIGNALS_OAUTH_KEY'], ENV['37SIGNALS_OAUTH_SECRET'] + provider '37signals', ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'], ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'] provider :github, ENV['GITHUB_OAUTH_KEY'], ENV['GITHUB_OAUTH_SECRET'] end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index f874f887..1f8dddc0 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -59,8 +59,8 @@ describe Service do stub_request(:post, "https://launchpad.37signals.com/authorization/token?client_id=TESTKEY&client_secret=TESTSECRET&refresh_token=refreshtokentest&type=refresh"). to_return(:status => 200, :body => '{"expires_in":1209600,"access_token": "NEWTOKEN"}', :headers => {}) @service.provider = '37signals' - ENV['37SIGNALS_OAUTH_KEY'] = 'TESTKEY' - ENV['37SIGNALS_OAUTH_SECRET'] = 'TESTSECRET' + ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'] = 'TESTKEY' + ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'] = 'TESTSECRET' @service.refresh_token = 'refreshtokentest' @service.refresh_token! @service.token.should == 'NEWTOKEN'