Merge branch 'omniauth' of https://github.com/dsander/huginn into dsander-omniauth

Conflicts:
Gemfile.lock

Andrew Cantino 10 年之前
父节点
当前提交
78f6150b26
共有 40 个文件被更改,包括 914 次插入110 次删除
  1. 12 0
      .env.example
  2. 1 1
      .travis.yml
  3. 5 0
      Gemfile
  4. 55 24
      Gemfile.lock
  5. 2 0
      app/assets/javascripts/application.js.coffee.erb
  6. 32 0
      app/concerns/oauthable.rb
  7. 6 4
      app/concerns/twitter_concern.rb
  8. 40 0
      app/controllers/services_controller.rb
  9. 1 0
      app/models/agent.rb
  10. 9 13
      app/models/agents/basecamp_agent.rb
  11. 1 5
      app/models/agents/twitter_publish_agent.rb
  12. 1 5
      app/models/agents/twitter_stream_agent.rb
  13. 1 5
      app/models/agents/twitter_user_agent.rb
  14. 18 5
      app/models/scenario_import.rb
  15. 70 0
      app/models/service.rb
  16. 5 0
      app/models/user.rb
  17. 10 1
      app/views/agents/_form.html.erb
  18. 1 0
      app/views/layouts/_navigation.html.erb
  19. 10 0
      app/views/scenario_imports/_step_two.html.erb
  20. 52 0
      app/views/services/index.html.erb
  21. 5 0
      config/initializers/omniauth.rb
  22. 7 0
      config/routes.rb
  23. 18 0
      db/migrate/20140515211100_create_services.rb
  24. 5 0
      db/migrate/20140525150040_add_service_id_to_agents.rb
  25. 55 0
      db/migrate/20140525150140_migrate_agents_to_service_authentication.rb
  26. 46 25
      db/schema.rb
  27. 57 0
      spec/controllers/services_controller_spec.rb
  28. 43 0
      spec/data_fixtures/services/37signals.json
  29. 52 0
      spec/data_fixtures/services/github.json
  30. 66 0
      spec/data_fixtures/services/twitter.json
  31. 6 0
      spec/fixtures/agents.yml
  32. 17 0
      spec/fixtures/services.yml
  33. 6 22
      spec/models/agents/basecamp_agent_spec.rb
  34. 1 0
      spec/models/agents/twitter_publish_agent_spec.rb
  35. 1 0
      spec/models/agents/twitter_stream_agent_spec.rb
  36. 2 0
      spec/models/agents/twitter_user_agent_spec.rb
  37. 29 0
      spec/models/concerns/oauthable.rb
  38. 55 0
      spec/models/scenario_import_spec.rb
  39. 109 0
      spec/models/service_spec.rb
  40. 2 0
      spec/spec_helper.rb

+ 12 - 0
.env.example

@@ -71,6 +71,18 @@ EMAIL_FROM_ADDRESS=from_address@gmail.com
71 71
 AGENT_LOG_LENGTH=200
72 72
 
73 73
 #############################
74
+#    OAuth Configuration    #
75
+#############################
76
+TWITTER_OAUTH_KEY=
77
+TWITTER_OAUTH_SECRET=
78
+
79
+THIRTY_SEVEN_SIGNALS_OAUTH_KEY=
80
+THIRTY_SEVEN_SIGNALS_OAUTH_SECRET=
81
+
82
+GITHUB_OAUTH_KEY=
83
+GITHUB_OAUTH_SECRET=
84
+
85
+#############################
74 86
 #  AWS and Mechanical Turk  #
75 87
 #############################
76 88
 

+ 1 - 1
.travis.yml

@@ -1,7 +1,7 @@
1 1
 language: ruby
2 2
 bundler_args: --without development production
3 3
 env:
4
-  - APP_SECRET_TOKEN=b2724973fd81c2f4ac0f92ac48eb3f0152c4a11824c122bcf783419a4c51d8b9bba81c8ba6a66c7de599677c7f486242cf819775c433908e77c739c5c8ae118d
4
+  - APP_SECRET_TOKEN=b2724973fd81c2f4ac0f92ac48eb3f0152c4a11824c122bcf783419a4c51d8b9bba81c8ba6a66c7de599677c7f486242cf819775c433908e77c739c5c8ae118d TWITTER_OAUTH_KEY=twitteroauthkey TWITTER_OAUTH_SECRET=twitteroauthsecret
5 5
 rvm:
6 6
   - 2.0.0
7 7
   - 2.1.1

+ 5 - 0
Gemfile

@@ -78,6 +78,11 @@ gem 'slack-notifier', '~> 0.5.0'
78 78
 gem 'therubyracer', '~> 0.12.1'
79 79
 gem 'mqtt'
80 80
 
81
+gem 'omniauth'
82
+gem 'omniauth-twitter'
83
+gem 'omniauth-37signals'
84
+gem 'omniauth-github'
85
+
81 86
 group :development do
82 87
   gem 'binding_of_caller'
83 88
   gem 'better_errors'

+ 55 - 24
Gemfile.lock

@@ -60,10 +60,10 @@ GEM
60 60
     coffee-rails (4.0.1)
61 61
       coffee-script (>= 2.2.0)
62 62
       railties (>= 4.0.0, < 5.0)
63
-    coffee-script (2.2.0)
63
+    coffee-script (2.3.0)
64 64
       coffee-script-source
65 65
       execjs
66
-    coffee-script-source (1.7.0)
66
+    coffee-script-source (1.7.1)
67 67
     cookiejar (0.3.2)
68 68
     coveralls (0.7.0)
69 69
       multi_json (~> 1.3)
@@ -154,7 +154,7 @@ GEM
154 154
     httparty (0.13.1)
155 155
       json (~> 1.8)
156 156
       multi_xml (>= 0.5.2)
157
-    i18n (0.6.9)
157
+    i18n (0.6.11)
158 158
     jquery-rails (3.1.1)
159 159
       railties (>= 3.0, < 5.0)
160 160
       thor (>= 0.14, < 2.0)
@@ -181,7 +181,7 @@ GEM
181 181
     method_source (0.8.2)
182 182
     mime-types (1.25.1)
183 183
     mini_portile (0.6.0)
184
-    minitest (5.3.5)
184
+    minitest (5.4.0)
185 185
     mqtt (0.2.0)
186 186
     multi_json (1.10.1)
187 187
     multi_xml (0.5.5)
@@ -189,14 +189,35 @@ GEM
189 189
     mysql2 (0.3.16)
190 190
     naught (1.0.0)
191 191
     net-ftp-list (3.2.8)
192
-    nokogiri (1.6.2.1)
192
+    nokogiri (1.6.3.1)
193 193
       mini_portile (= 0.6.0)
194
+    oauth (0.4.7)
194 195
     oauth2 (0.9.4)
195 196
       faraday (>= 0.8, < 0.10)
196 197
       jwt (~> 1.0)
197 198
       multi_json (~> 1.3)
198 199
       multi_xml (~> 0.5)
199 200
       rack (~> 1.2)
201
+    omniauth (1.2.2)
202
+      hashie (>= 1.2, < 4)
203
+      rack (~> 1.0)
204
+    omniauth-37signals (1.0.5)
205
+      omniauth (~> 1.0)
206
+      omniauth-oauth2 (~> 1.0)
207
+    omniauth-github (1.1.2)
208
+      omniauth (~> 1.0)
209
+      omniauth-oauth2 (~> 1.1)
210
+    omniauth-oauth (1.0.1)
211
+      oauth
212
+      omniauth (~> 1.0)
213
+    omniauth-oauth2 (1.1.2)
214
+      faraday (>= 0.8, < 0.10)
215
+      multi_json (~> 1.3)
216
+      oauth2 (~> 0.9.3)
217
+      omniauth (~> 1.2)
218
+    omniauth-twitter (1.0.1)
219
+      multi_json (~> 1.3)
220
+      omniauth-oauth (~> 1.0)
200 221
     orm_adapter (0.5.0)
201 222
     pg (0.17.1)
202 223
     polyglot (0.3.5)
@@ -233,27 +254,33 @@ GEM
233 254
       thor (>= 0.18.1, < 2.0)
234 255
     raindrops (0.13.0)
235 256
     rake (10.3.2)
257
+    rdoc (4.1.1)
258
+      json (~> 1.4)
236 259
     ref (1.0.5)
237
-    rest-client (1.6.7)
238
-      mime-types (>= 1.16)
260
+    rest-client (1.6.8)
261
+      mime-types (~> 1.16)
262
+      rdoc (>= 2.4.2)
239 263
     retriable (1.4.1)
240 264
     rr (1.1.2)
241
-    rspec (2.14.1)
242
-      rspec-core (~> 2.14.0)
243
-      rspec-expectations (~> 2.14.0)
244
-      rspec-mocks (~> 2.14.0)
245
-    rspec-core (2.14.8)
246
-    rspec-expectations (2.14.5)
265
+    rspec (2.99.0)
266
+      rspec-core (~> 2.99.0)
267
+      rspec-expectations (~> 2.99.0)
268
+      rspec-mocks (~> 2.99.0)
269
+    rspec-collection_matchers (1.0.0)
270
+      rspec-expectations (>= 2.99.0.beta1)
271
+    rspec-core (2.99.1)
272
+    rspec-expectations (2.99.2)
247 273
       diff-lcs (>= 1.1.3, < 2.0)
248
-    rspec-mocks (2.14.6)
249
-    rspec-rails (2.14.2)
274
+    rspec-mocks (2.99.2)
275
+    rspec-rails (2.99.0)
250 276
       actionpack (>= 3.0)
251 277
       activemodel (>= 3.0)
252 278
       activesupport (>= 3.0)
253 279
       railties (>= 3.0)
254
-      rspec-core (~> 2.14.0)
255
-      rspec-expectations (~> 2.14.0)
256
-      rspec-mocks (~> 2.14.0)
280
+      rspec-collection_matchers
281
+      rspec-core (~> 2.99.0)
282
+      rspec-expectations (~> 2.99.0)
283
+      rspec-mocks (~> 2.99.0)
257 284
     rturk (2.12.1)
258 285
       erector
259 286
       nokogiri
@@ -269,9 +296,9 @@ GEM
269 296
       sass (~> 3.2.0)
270 297
       sprockets (~> 2.8, <= 2.11.0)
271 298
       sprockets-rails (~> 2.0)
272
-    select2-rails (3.5.7)
299
+    select2-rails (3.5.9)
273 300
       thor (~> 0.14)
274
-    shoulda-matchers (2.6.1)
301
+    shoulda-matchers (2.6.2)
275 302
       activesupport (>= 3.0.0)
276 303
     signet (0.5.1)
277 304
       addressable (>= 2.2.3)
@@ -280,13 +307,13 @@ GEM
280 307
       multi_json (>= 1.0.0)
281 308
     simple-rss (1.3.1)
282 309
     simple_oauth (0.2.0)
283
-    simplecov (0.8.2)
310
+    simplecov (0.9.0)
284 311
       docile (~> 1.1.0)
285 312
       multi_json
286 313
       simplecov-html (~> 0.8.0)
287 314
     simplecov-html (0.8.0)
288 315
     slack-notifier (0.5.0)
289
-    slop (3.5.0)
316
+    slop (3.6.0)
290 317
     sprockets (2.11.0)
291 318
       hike (~> 1.2)
292 319
       multi_json (~> 1.0)
@@ -309,7 +336,7 @@ GEM
309 336
     treetop (1.4.15)
310 337
       polyglot
311 338
       polyglot (>= 0.3.1)
312
-    twilio-ruby (3.11.5)
339
+    twilio-ruby (3.11.6)
313 340
       builder (>= 2.1.2)
314 341
       jwt (>= 0.1.2)
315 342
       multi_json (>= 1.3.0)
@@ -328,7 +355,7 @@ GEM
328 355
       ethon (>= 0.7.1)
329 356
     tzinfo (1.2.1)
330 357
       thread_safe (~> 0.1)
331
-    uglifier (2.5.1)
358
+    uglifier (2.5.3)
332 359
       execjs (>= 0.3.0)
333 360
       json (>= 1.8.0)
334 361
     unicorn (4.8.3)
@@ -394,6 +421,10 @@ DEPENDENCIES
394 421
   mysql2 (~> 0.3.16)
395 422
   net-ftp-list (~> 3.2.8)
396 423
   nokogiri (~> 1.6.1)
424
+  omniauth
425
+  omniauth-37signals
426
+  omniauth-github
427
+  omniauth-twitter
397 428
   pg
398 429
   protected_attributes (~> 1.0.8)
399 430
   pry

+ 2 - 0
app/assets/javascripts/application.js.coffee.erb

@@ -164,6 +164,8 @@ $(document).ready ->
164 164
 
165 165
         $(".description").html(json.description_html) if json.description_html?
166 166
 
167
+        $('.oauthable-form').html($(json.form).find('.oauthable-form').html()) if json.form?
168
+
167 169
         if $("#agent_options").hasClass("showing-default") || $("#agent_options").val().match(/\A\s*(\{\s*\}|)\s*\Z/g)
168 170
           window.jsonEditor.json = json.options
169 171
           window.jsonEditor.rebuild()

+ 32 - 0
app/concerns/oauthable.rb

@@ -0,0 +1,32 @@
1
+module Oauthable
2
+  extend ActiveSupport::Concern
3
+
4
+  included do |base|
5
+    @valid_oauth_providers = :all
6
+    attr_accessible :service_id
7
+    validates_presence_of :service_id
8
+  end
9
+
10
+  def oauthable?
11
+    true
12
+  end
13
+
14
+  def valid_services(current_user)
15
+    if valid_oauth_providers == :all
16
+      current_user.available_services
17
+    else
18
+      current_user.available_services.where(provider: valid_oauth_providers)
19
+    end
20
+  end
21
+
22
+  def valid_oauth_providers
23
+    self.class.valid_oauth_providers
24
+  end
25
+
26
+  module ClassMethods
27
+    def valid_oauth_providers(*providers)
28
+      return @valid_oauth_providers if providers == []
29
+      @valid_oauth_providers = providers
30
+    end
31
+  end
32
+end

+ 6 - 4
app/concerns/twitter_concern.rb

@@ -1,8 +1,10 @@
1 1
 module TwitterConcern
2 2
   extend ActiveSupport::Concern
3
+  include Oauthable
3 4
 
4 5
   included do
5 6
     validate :validate_twitter_options
7
+    valid_oauth_providers :twitter
6 8
   end
7 9
 
8 10
   def validate_twitter_options
@@ -15,19 +17,19 @@ module TwitterConcern
15 17
   end
16 18
 
17 19
   def twitter_consumer_key
18
-    options['consumer_key'].presence || credential('twitter_consumer_key')
20
+    ENV['TWITTER_OAUTH_KEY']
19 21
   end
20 22
 
21 23
   def twitter_consumer_secret
22
-    options['consumer_secret'].presence || credential('twitter_consumer_secret')
24
+    ENV['TWITTER_OAUTH_SECRET']
23 25
   end
24 26
 
25 27
   def twitter_oauth_token
26
-    options['oauth_token'].presence || options['access_key'].presence || credential('twitter_oauth_token')
28
+    self.service.token
27 29
   end
28 30
 
29 31
   def twitter_oauth_token_secret
30
-    options['oauth_token_secret'].presence || options['access_secret'].presence || credential('twitter_oauth_token_secret')
32
+    self.service.secret
31 33
   end
32 34
 
33 35
   def twitter

+ 40 - 0
app/controllers/services_controller.rb

@@ -0,0 +1,40 @@
1
+class ServicesController < ApplicationController
2
+
3
+  def index
4
+    @services = current_user.services.page(params[:page])
5
+
6
+    respond_to do |format|
7
+      format.html
8
+      format.json { render json: @services }
9
+    end
10
+  end
11
+
12
+  def destroy
13
+    @services = current_user.services.find(params[:id])
14
+    @services.destroy
15
+
16
+    respond_to do |format|
17
+      format.html { redirect_to services_path }
18
+      format.json { head :no_content }
19
+    end
20
+  end
21
+
22
+  def toggle_availability
23
+    @service = current_user.services.find(params[:id])
24
+    @service.toggle_availability!
25
+
26
+    respond_to do |format|
27
+      format.html { redirect_to services_path }
28
+      format.json { render json: @service }
29
+    end
30
+  end
31
+
32
+  def callback
33
+    @service = current_user.services.initialize_or_update_via_omniauth(request.env['omniauth.auth'])
34
+    if @service && @service.save
35
+      redirect_to services_path, notice: "The service was successfully created."
36
+    else
37
+      redirect_to services_path, error: "Error creating the service."
38
+    end
39
+  end
40
+end

+ 1 - 0
app/models/agent.rb

@@ -44,6 +44,7 @@ class Agent < ActiveRecord::Base
44 44
   after_save :possibly_update_event_expirations
45 45
 
46 46
   belongs_to :user, :inverse_of => :agents
47
+  belongs_to :service
47 48
   has_many :events, -> { order("events.id desc") }, :dependent => :delete_all, :inverse_of => :agent
48 49
   has_one  :most_recent_event, :inverse_of => :agent, :class_name => "Event", :order => "events.id desc"
49 50
   has_many :logs,  -> { order("agent_logs.id desc") }, :dependent => :delete_all, :inverse_of => :agent, :class_name => "AgentLog"

+ 9 - 13
app/models/agents/basecamp_agent.rb

@@ -2,17 +2,18 @@ module Agents
2 2
   class BasecampAgent < Agent
3 3
     cannot_receive_events!
4 4
 
5
+    include Oauthable
6
+    valid_oauth_providers '37signals'
7
+
5 8
     description <<-MD
6 9
       The BasecampAgent checks a Basecamp project for new Events
7 10
 
8
-      It is required that you enter your Basecamp credentials (`username` and `password`).
11
+      To be able to use this Agent you need to authenticate with 37signals in the [Services](/services) section first.
9 12
 
10
-      You also need to provide your Basecamp `user_id` and the `project_id` of the project you want to monitor.
13
+      You need to provide the `project_id` of the project you want to monitor.
11 14
       If you have your Basecamp project opened in your browser you can find the user_id and project_id as follows:
12 15
 
13
-      `https://basecamp.com/`
14
-      user_id
15
-      `/projects/`
16
+      `https://basecamp.com/123456/projects/`
16 17
       project_id
17 18
       `-explore-basecamp`
18 19
     MD
@@ -45,17 +46,11 @@ module Agents
45 46
 
46 47
     def default_options
47 48
       {
48
-        'username' => '',
49
-        'password' => '',
50
-        'user_id' => '',
51 49
         'project_id' => '',
52 50
       }
53 51
     end
54 52
 
55 53
     def validate_options
56
-      errors.add(:base, "you need to specify your basecamp username") unless options['username'].present?
57
-      errors.add(:base, "you need to specify your basecamp password") unless options['password'].present?
58
-      errors.add(:base, "you need to specify your basecamp user id") unless options['user_id'].present?
59 54
       errors.add(:base, "you need to specify the basecamp project id of which you want to receive events") unless options['project_id'].present?
60 55
     end
61 56
 
@@ -64,6 +59,7 @@ module Agents
64 59
     end
65 60
 
66 61
     def check
62
+      self.service.prepare_request
67 63
       reponse = HTTParty.get request_url, request_options.merge(query_parameters)
68 64
       memory[:last_run] = Time.now.utc.iso8601
69 65
       if last_check_at != nil
@@ -76,11 +72,11 @@ module Agents
76 72
 
77 73
   private
78 74
     def request_url
79
-      "https://basecamp.com/#{URI.encode(interpolated[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
75
+      "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
80 76
     end
81 77
 
82 78
     def request_options
83
-      {:basic_auth => {:username => interpolated[:username], :password => interpolated[:password]}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
79
+      {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => "Bearer \"#{self.service.token}\""}}
84 80
     end
85 81
 
86 82
     def query_parameters

+ 1 - 5
app/models/agents/twitter_publish_agent.rb

@@ -9,11 +9,7 @@ module Agents
9 9
     description <<-MD
10 10
       The TwitterPublishAgent publishes tweets from the events it receives.
11 11
 
12
-      Twitter credentials must be supplied as either [credentials](/user_credentials) called
13
-      `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`,
14
-      or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`.
15
-
16
-      To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
12
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
17 13
 
18 14
       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.
19 15
 

+ 1 - 5
app/models/agents/twitter_stream_agent.rb

@@ -10,11 +10,7 @@ module Agents
10 10
       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.
11 11
       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.
12 12
 
13
-      Twitter credentials must be supplied as either [credentials](/user_credentials) called
14
-      `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`,
15
-      or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`.
16
-
17
-      To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
13
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
18 14
 
19 15
       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.
20 16
 

+ 1 - 5
app/models/agents/twitter_user_agent.rb

@@ -9,11 +9,7 @@ module Agents
9 9
     description <<-MD
10 10
       The TwitterUserAgent follows the timeline of a specified Twitter user.
11 11
 
12
-      Twitter credentials must be supplied as either [credentials](/user_credentials) called
13
-      `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`,
14
-      or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`.
15
-
16
-      To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
12
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
17 13
 
18 14
       You must also provide the `username` of the Twitter user to monitor.
19 15
 

+ 18 - 5
app/models/scenario_import.rb

@@ -76,17 +76,19 @@ class ScenarioImport
76 76
         agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present?
77 77
         agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present?
78 78
         agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true"
79
+        agent.service_id = agent_diff.service_id.updated if agent_diff.service_id.present?
79 80
         unless agent.save
80 81
           success = false
81 82
           errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}")
82 83
         end
83 84
         agent
84 85
       end
85
-
86
-      links.each do |link|
87
-        receiver = created_agents[link['receiver']]
88
-        source = created_agents[link['source']]
89
-        receiver.sources << source unless receiver.sources.include?(source)
86
+      if success
87
+        links.each do |link|
88
+          receiver = created_agents[link['receiver']]
89
+          source = created_agents[link['source']]
90
+          receiver.sources << source unless receiver.sources.include?(source)
91
+        end
90 92
       end
91 93
     end
92 94
 
@@ -149,6 +151,9 @@ class ScenarioImport
149 151
           errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.")
150 152
         end
151 153
       end
154
+      if agent_diff.requires_service? && merges.present? && merges[index.to_s].present? && merges[index.to_s]['service_id'].present?
155
+        agent_diff.service_id = AgentDiff::FieldDiff.new(merges[index.to_s]['service_id'].to_i)
156
+      end
152 157
       agent_diff
153 158
     end
154 159
   end
@@ -192,6 +197,10 @@ class ScenarioImport
192 197
       @requires_merge
193 198
     end
194 199
 
200
+    def requires_service?
201
+      !!agent_instance.try(:oauthable?)
202
+    end
203
+
195 204
     def store!(agent_data)
196 205
       self.type = FieldDiff.new(agent_data["type"].split("::").pop)
197 206
       self.options = FieldDiff.new(agent_data['options'] || {})
@@ -252,5 +261,9 @@ class ScenarioImport
252 261
         key.gsub(/[^a-zA-Z0-9_-]/, '')
253 262
       end
254 263
     end
264
+
265
+    def agent_instance
266
+      "Agents::#{self.type.updated}".constantize.new
267
+    end
255 268
   end
256 269
 end

+ 70 - 0
app/models/service.rb

@@ -0,0 +1,70 @@
1
+class Service < ActiveRecord::Base
2
+  attr_accessible :provider, :name, :token, :secret, :refresh_token, :expires_at, :global, :options
3
+
4
+  serialize :options, Hash
5
+
6
+  belongs_to :user
7
+  has_many :agents
8
+
9
+  validates_presence_of :user_id, :provider, :name, :token
10
+
11
+  before_destroy :disable_agents
12
+
13
+  def disable_agents
14
+    self.agents.each do |agent|
15
+      agent.service_id = nil
16
+      agent.disabled = true
17
+      agent.save!(validate: false)
18
+    end
19
+  end
20
+
21
+  def toggle_availability!
22
+    self.global = !self.global
23
+    self.save!
24
+  end
25
+
26
+  def prepare_request
27
+    if self.expires_at && Time.now > self.expires_at
28
+      self.refresh_token!
29
+    end
30
+  end
31
+
32
+  def refresh_token!
33
+    response = HTTParty.post(endpoint, query: {
34
+                  type:          'refresh',
35
+                  client_id:     ENV["#{self.provider.upcase}_OAUTH_KEY"],
36
+                  client_secret: ENV["#{self.provider.upcase}_OAUTH_SECRET"],
37
+                  refresh_token: self.refresh_token
38
+    })
39
+    data = JSON.parse(response.body)
40
+    self.update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || self.refresh_token)
41
+  end
42
+
43
+  def self.initialize_or_update_via_omniauth(omniauth)
44
+    case omniauth['provider']
45
+    when 'twitter'
46
+      find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['nickname']).tap do |service|
47
+        service.assign_attributes(token: omniauth['credentials']['token'], secret: omniauth['credentials']['secret'])
48
+      end
49
+    when 'github'
50
+      find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['nickname']).tap do |service|
51
+        service.assign_attributes(token: omniauth['credentials']['token'])
52
+      end
53
+    when '37signals'
54
+      find_or_initialize_by(provider: omniauth['provider'], name: omniauth['info']['name']).tap do |service|
55
+        service.assign_attributes(token: omniauth['credentials']['token'],
56
+                                  refresh_token: omniauth['credentials']['refresh_token'],
57
+                                  expires_at: Time.at(omniauth['credentials']['expires_at']),
58
+                                  options: {user_id: omniauth['extra']['accounts'][0]['id']})
59
+      end
60
+    else
61
+      false
62
+    end
63
+  end
64
+
65
+  private
66
+  def endpoint
67
+    client_options = "OmniAuth::Strategies::#{OmniAuth::Utils.camelize(self.provider)}".constantize.default_options['client_options']
68
+    URI.join(client_options['site'], client_options['token_url'])
69
+  end
70
+end

+ 5 - 0
app/models/user.rb

@@ -27,6 +27,11 @@ class User < ActiveRecord::Base
27 27
   has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
28 28
   has_many :logs, :through => :agents, :class_name => "AgentLog"
29 29
   has_many :scenarios, :inverse_of => :user, :dependent => :destroy
30
+  has_many :services, -> { order("services.name")}, :dependent => :destroy
31
+
32
+  def available_services
33
+    Service.where("user_id = ? or global = true", self.id).order("services.name desc")
34
+  end
30 35
 
31 36
   # Allow users to login via either email or username.
32 37
   def self.find_first_by_auth_conditions(warden_conditions)

+ 10 - 1
app/views/agents/_form.html.erb

@@ -25,11 +25,20 @@
25 25
             </div>
26 26
           <% end %>
27 27
 
28
-          <div class="form-group">
28
+          <div class="form-group type-select">
29 29
             <%= f.label :name %>
30 30
             <%= f.text_field :name, :class => 'form-control' %>
31 31
           </div>
32 32
 
33
+          <div class='oauthable-form'>
34
+            <% if @agent.try(:oauthable?) %>
35
+              <div class="form-group type-select">
36
+                <%= f.label :service %>
37
+                <%= 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' %>
38
+              </div>
39
+            <% end %>
40
+          </div>
41
+
33 42
           <div class="form-group">
34 43
             <%= f.label :schedule, :class => 'control-label' %>
35 44
             <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">

+ 1 - 0
app/views/layouts/_navigation.html.erb

@@ -16,6 +16,7 @@
16 16
       <%= nav_link "Scenarios", scenarios_path %>
17 17
       <%= nav_link "Events", events_path %>
18 18
       <%= nav_link "Credentials", user_credentials_path %>
19
+      <%= nav_link "Services", services_path %>
19 20
     </ul>
20 21
   <% end %>
21 22
   

+ 10 - 0
app/views/scenario_imports/_step_two.html.erb

@@ -120,6 +120,16 @@
120 120
           </div>
121 121
         <% end %>
122 122
       </div>
123
+      <% if agent_diff.requires_service? %>
124
+        <div class='row'>
125
+          <div class='col-md-4'>
126
+            <div class="form-group type-select">
127
+              <%= label_tag "scenario_import[merges][#{index}][service_id]", 'Service' %>
128
+              <%= 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' %>
129
+            </div>
130
+          </div>
131
+        </div>
132
+      <% end %>
123 133
     </div>
124 134
   <% end %>
125 135
 </div>

+ 52 - 0
app/views/services/index.html.erb

@@ -0,0 +1,52 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Your Services
7
+        </h2>
8
+      </div>
9
+      <p>
10
+        Before you can authenticate with a service, you need to set it up. Have a look at the
11
+        <%= link_to 'wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: :_blank %>
12
+        for guidance.
13
+      </p>
14
+      <p><%= link_to "Authenticate with Twitter", "/auth/twitter" %></p>
15
+      <p><%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %></p>
16
+      <p><%= link_to "Authenticate with Github", "/auth/github" %></p>
17
+      <hr>
18
+
19
+      <div class='table-responsive'>
20
+        <table class='table table-striped events'>
21
+          <tr>
22
+            <th>Provider</th>
23
+            <th>Username</th>
24
+            <th>Global?</th>
25
+            <th></th>
26
+          </tr>
27
+
28
+        <% @services.each do |service| %>
29
+          <tr>
30
+            <td><%= service.provider %></td>
31
+            <td><%= service.name %></td>
32
+            <td><%= service.global ? 'Yes' : 'No' %></td>
33
+            <td>
34
+              <div class="btn-group btn-group-xs">
35
+                <% if service.global %>
36
+                  <%= 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" %>
37
+                <% else %>
38
+                   <%= 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" %>
39
+                <% end %>
40
+                <%= link_to 'Delete', service_path(service), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
41
+              </div>
42
+            </td>
43
+          </tr>
44
+        <% end %>
45
+        </table>
46
+      </div>
47
+
48
+      <%= paginate @services, :theme => 'twitter-bootstrap-3' %>
49
+    </div>
50
+  </div>
51
+</div>
52
+

+ 5 - 0
config/initializers/omniauth.rb

@@ -0,0 +1,5 @@
1
+Rails.application.config.middleware.use OmniAuth::Builder do
2
+  provider :twitter, ENV['TWITTER_OAUTH_KEY'], ENV['TWITTER_OAUTH_SECRET'], authorize_params: {force_login: 'true', use_authorize: 'true'}
3
+  provider '37signals', ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'], ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET']
4
+  provider :github, ENV['GITHUB_OAUTH_KEY'], ENV['GITHUB_OAUTH_SECRET']
5
+end

+ 7 - 0
config/routes.rb

@@ -45,6 +45,12 @@ Huginn::Application.routes.draw do
45 45
 
46 46
   resources :user_credentials, :except => :show
47 47
 
48
+  resources :services, :only => [:index, :destroy] do
49
+    member do
50
+      post :toggle_availability
51
+    end
52
+  end
53
+
48 54
   get "/worker_status" => "worker_status#show"
49 55
 
50 56
   post "/users/:user_id/update_location/:secret" => "user_location_updates#create"
@@ -56,6 +62,7 @@ Huginn::Application.routes.draw do
56 62
 #  get "/delayed_job" => DelayedJobWeb, :anchor => false
57 63
 
58 64
   devise_for :users, :sign_out_via => [ :post, :delete ]
65
+  get '/auth/:provider/callback', to: 'services#callback'
59 66
 
60 67
   get "/about" => "home#about"
61 68
   root :to => "home#index"

+ 18 - 0
db/migrate/20140515211100_create_services.rb

@@ -0,0 +1,18 @@
1
+class CreateServices < ActiveRecord::Migration
2
+  def change
3
+    create_table :services do |t|
4
+      t.integer :user_id, null: false
5
+      t.string :provider, null: false
6
+      t.string :name, null: false
7
+      t.text :token, null: false
8
+      t.text :secret
9
+      t.text :refresh_token
10
+      t.datetime :expires_at
11
+      t.boolean :global, default: false
12
+      t.text :options
13
+      t.timestamps
14
+    end
15
+    add_index :services, :user_id
16
+    add_index :services, [:user_id, :global]
17
+  end
18
+end

+ 5 - 0
db/migrate/20140525150040_add_service_id_to_agents.rb

@@ -0,0 +1,5 @@
1
+class AddServiceIdToAgents < ActiveRecord::Migration
2
+  def change
3
+    add_column :agents, :service_id, :integer
4
+  end
5
+end

+ 55 - 0
db/migrate/20140525150140_migrate_agents_to_service_authentication.rb

@@ -0,0 +1,55 @@
1
+class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration
2
+  def twitter_consumer_key(agent)
3
+    agent.options['consumer_key'].presence || agent.credential('twitter_consumer_key')
4
+  end
5
+
6
+  def twitter_consumer_secret(agent)
7
+    agent.options['consumer_secret'].presence || agent.credential('twitter_consumer_secret')
8
+  end
9
+
10
+  def twitter_oauth_token(agent)
11
+    agent.options['oauth_token'].presence || agent.options['access_key'].presence || agent.credential('twitter_oauth_token')
12
+  end
13
+
14
+  def twitter_oauth_token_secret(agent)
15
+    agent.options['oauth_token_secret'].presence || agent.options['access_secret'].presence || agent.credential('twitter_oauth_token_secret')
16
+  end
17
+
18
+  def up
19
+    agents = Agent.where(type: ['Agents::TwitterUserAgent', 'Agents::TwitterStreamAgent', 'Agents::TwitterPublishAgent']).each do |agent|
20
+      service = agent.user.services.create!(
21
+        provider: 'twitter',
22
+        name: "Migrated '#{agent.name}'",
23
+        token: twitter_oauth_token(agent),
24
+        secret: twitter_oauth_token_secret(agent)
25
+      )
26
+      agent.service_id = service.id
27
+      agent.save!(validate: false)
28
+    end
29
+    if agents.length > 0
30
+      puts <<-EOF.strip_heredoc
31
+
32
+        Your Twitter agents were successfully migrated. You need to update your .env file and add the following two lines:
33
+
34
+        TWITTER_OAUTH_KEY=#{twitter_consumer_key(agents.first)}
35
+        TWITTER_OAUTH_SECRET=#{twitter_consumer_secret(agents.first)}
36
+
37
+
38
+      EOF
39
+    end
40
+    if Agent.where(type: ['Agents::BasecampAgent']).count > 0
41
+      puts <<-EOF.strip_heredoc
42
+
43
+        Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate huginn to use it. 
44
+        Have a look at the wiki (https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications) if you need help.
45
+
46
+
47
+      EOF
48
+    end
49
+  end
50
+
51
+  def down
52
+    raise ActiveRecord::IrreversibleMigration, "Cannot revert migration to OAuth services"
53
+  end
54
+end
55
+

+ 46 - 25
db/schema.rb

@@ -11,21 +11,24 @@
11 11
 #
12 12
 # It's strongly recommended that you check this file into your version control system.
13 13
 
14
-ActiveRecord::Schema.define(version: 20140605032822) do
14
+ActiveRecord::Schema.define(version: 20140723110551) do
15
+
16
+  # These are extensions that must be enabled in order to support this database
17
+  enable_extension "plpgsql"
15 18
 
16 19
   create_table "agent_logs", force: true do |t|
17
-    t.integer  "agent_id",                                       null: false
18
-    t.text     "message",           limit: 16777215,             null: false
19
-    t.integer  "level",                              default: 3, null: false
20
+    t.integer  "agent_id",                      null: false
21
+    t.text     "message",                       null: false
22
+    t.integer  "level",             default: 3, null: false
20 23
     t.integer  "inbound_event_id"
21 24
     t.integer  "outbound_event_id"
22
-    t.datetime "created_at",                                     null: false
23
-    t.datetime "updated_at",                                     null: false
25
+    t.datetime "created_at",                    null: false
26
+    t.datetime "updated_at",                    null: false
24 27
   end
25 28
 
26 29
   create_table "agents", force: true do |t|
27 30
     t.integer  "user_id"
28
-    t.text     "options",               limit: 16777215
31
+    t.text     "options"
29 32
     t.string   "type"
30 33
     t.string   "name"
31 34
     t.string   "schedule"
@@ -33,16 +36,17 @@ ActiveRecord::Schema.define(version: 20140605032822) do
33 36
     t.datetime "last_check_at"
34 37
     t.datetime "last_receive_at"
35 38
     t.integer  "last_checked_event_id"
36
-    t.datetime "created_at",                                               null: false
37
-    t.datetime "updated_at",                                               null: false
38
-    t.text     "memory",                limit: 2147483647
39
+    t.datetime "created_at",                            null: false
40
+    t.datetime "updated_at",                            null: false
41
+    t.text     "memory"
39 42
     t.datetime "last_web_request_at"
43
+    t.integer  "keep_events_for",       default: 0,     null: false
40 44
     t.datetime "last_event_at"
41 45
     t.datetime "last_error_log_at"
42
-    t.integer  "keep_events_for",                          default: 0,     null: false
43
-    t.boolean  "propagate_immediately",                    default: false, null: false
44
-    t.boolean  "disabled",                                 default: false, null: false
45
-    t.string   "guid",                                                     null: false
46
+    t.boolean  "propagate_immediately", default: false, null: false
47
+    t.boolean  "disabled",              default: false, null: false
48
+    t.integer  "service_id"
49
+    t.string   "guid",                                  null: false
46 50
   end
47 51
 
48 52
   add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
@@ -51,17 +55,17 @@ ActiveRecord::Schema.define(version: 20140605032822) do
51 55
   add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
52 56
 
53 57
   create_table "delayed_jobs", force: true do |t|
54
-    t.integer  "priority",                    default: 0
55
-    t.integer  "attempts",                    default: 0
56
-    t.text     "handler",    limit: 16777215
57
-    t.text     "last_error", limit: 16777215
58
+    t.integer  "priority",   default: 0
59
+    t.integer  "attempts",   default: 0
60
+    t.text     "handler"
61
+    t.text     "last_error"
58 62
     t.datetime "run_at"
59 63
     t.datetime "locked_at"
60 64
     t.datetime "failed_at"
61 65
     t.string   "locked_by"
62 66
     t.string   "queue"
63
-    t.datetime "created_at",                              null: false
64
-    t.datetime "updated_at",                              null: false
67
+    t.datetime "created_at",             null: false
68
+    t.datetime "updated_at",             null: false
65 69
   end
66 70
 
67 71
   add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
@@ -69,11 +73,11 @@ ActiveRecord::Schema.define(version: 20140605032822) do
69 73
   create_table "events", force: true do |t|
70 74
     t.integer  "user_id"
71 75
     t.integer  "agent_id"
72
-    t.decimal  "lat",                           precision: 15, scale: 10
73
-    t.decimal  "lng",                           precision: 15, scale: 10
74
-    t.text     "payload",    limit: 2147483647
75
-    t.datetime "created_at",                                              null: false
76
-    t.datetime "updated_at",                                              null: false
76
+    t.decimal  "lat",        precision: 15, scale: 10
77
+    t.decimal  "lng",        precision: 15, scale: 10
78
+    t.text     "payload"
79
+    t.datetime "created_at",                           null: false
80
+    t.datetime "updated_at",                           null: false
77 81
     t.datetime "expires_at"
78 82
   end
79 83
 
@@ -115,6 +119,23 @@ ActiveRecord::Schema.define(version: 20140605032822) do
115 119
 
116 120
   add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
117 121
 
122
+  create_table "services", force: true do |t|
123
+    t.integer  "user_id"
124
+    t.string   "provider"
125
+    t.string   "name"
126
+    t.text     "token"
127
+    t.text     "secret"
128
+    t.text     "refresh_token"
129
+    t.datetime "expires_at"
130
+    t.boolean  "global",        default: false
131
+    t.text     "options"
132
+    t.datetime "created_at"
133
+    t.datetime "updated_at"
134
+  end
135
+
136
+  add_index "services", ["user_id", "global"], name: "index_accounts_on_user_id_and_global", using: :btree
137
+  add_index "services", ["user_id"], name: "index_accounts_on_user_id", using: :btree
138
+
118 139
   create_table "user_credentials", force: true do |t|
119 140
     t.integer  "user_id",                           null: false
120 141
     t.string   "credential_name",                   null: false

+ 57 - 0
spec/controllers/services_controller_spec.rb

@@ -0,0 +1,57 @@
1
+require 'spec_helper'
2
+
3
+describe ServicesController do
4
+  before do
5
+    sign_in users(:bob)
6
+    OmniAuth.config.test_mode = true
7
+    request.env["omniauth.auth"] = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json')))
8
+  end
9
+
10
+  describe "GET index" do
11
+    it "only returns sevices of the current user" do
12
+      get :index
13
+      assigns(:services).all? {|i| i.user.should == users(:bob) }.should be_true
14
+    end
15
+  end
16
+
17
+  describe "POST toggle_availability" do
18
+    it "should work for service of the user" do
19
+      post :toggle_availability, :id => services(:generic).to_param
20
+      assigns(:service).should eq(services(:generic))
21
+      redirect_to(services_path)
22
+    end
23
+
24
+    it "should not work for a service of another user" do
25
+      lambda {
26
+        post :toggle_availability, :id => services(:global).to_param
27
+      }.should raise_error(ActiveRecord::RecordNotFound)
28
+    end
29
+  end
30
+
31
+  describe "DELETE destroy" do
32
+    it "destroys only services owned by the current user" do
33
+      expect {
34
+        delete :destroy, :id => services(:generic).to_param
35
+      }.to change(Service, :count).by(-1)
36
+
37
+      lambda {
38
+        delete :destroy, :id => services(:global).to_param
39
+      }.should raise_error(ActiveRecord::RecordNotFound)
40
+    end
41
+  end
42
+
43
+  describe "accepting a callback url" do
44
+    it "should update the users credentials" do
45
+      expect {
46
+        get :callback, provider: 'twitter'
47
+      }.to change { users(:bob).services.count }.by(1)
48
+    end
49
+
50
+    it "should not work with an unknown provider" do
51
+      request.env["omniauth.auth"]['provider'] = 'unknown'
52
+      expect {
53
+        get :callback, provider: 'unknown'
54
+      }.to change { users(:bob).services.count }.by(0)
55
+    end
56
+  end
57
+end

+ 43 - 0
spec/data_fixtures/services/37signals.json

@@ -0,0 +1,43 @@
1
+{
2
+  "provider": "37signals",
3
+  "uid": 12345,
4
+  "info": {
5
+    "email": "basecamp@none.de",
6
+    "first_name": "Dominik",
7
+    "last_name": "Sander",
8
+    "name": "Dominik Sander"
9
+  },
10
+  "credentials": {
11
+    "token": "abcde",
12
+    "refresh_token": "fghrefresh",
13
+    "expires_at": 1401554352,
14
+    "expires": true
15
+  },
16
+  "extra": {
17
+    "accounts": [
18
+      {
19
+        "product": "bcx",
20
+        "name": "Dominik Sander's Basecamp",
21
+        "id": 12345,
22
+        "href": "https://basecamp.com/12345/api/v1"
23
+      }
24
+    ],
25
+    "raw_info": {
26
+      "expires_at": "2014-05-31T16:39:12Z",
27
+      "identity": {
28
+        "first_name": "Dominik",
29
+        "last_name": "Sander",
30
+        "email_address": "basecamp@none.de",
31
+        "id": 12345
32
+      },
33
+      "accounts": [
34
+        {
35
+          "product": "bcx",
36
+          "name": "Dominik Sander's Basecamp",
37
+          "id": 12345,
38
+          "href": "https://basecamp.com/12345/api/v1"
39
+        }
40
+      ]
41
+    }
42
+  }
43
+}

+ 52 - 0
spec/data_fixtures/services/github.json

@@ -0,0 +1,52 @@
1
+{
2
+  "provider": "github",
3
+  "uid": "12345",
4
+  "info": {
5
+    "nickname": "dsander",
6
+    "email": null,
7
+    "name": "Dominik Sander",
8
+    "image": "https://avatars.githubusercontent.com/u/12345?",
9
+    "urls": {
10
+      "GitHub": "https://github.com/dsander",
11
+      "Blog": "http://www.dsander.de"
12
+    }
13
+  },
14
+  "credentials": {
15
+    "token": "agithubtoken",
16
+    "expires": false
17
+  },
18
+  "extra": {
19
+    "raw_info": {
20
+      "login": "dsander",
21
+      "id": 12345,
22
+      "avatar_url": "https://avatars.githubusercontent.com/u/12345?",
23
+      "gravatar_id": "fsdfsdf",
24
+      "url": "https://api.github.com/users/dsander",
25
+      "html_url": "https://github.com/dsander",
26
+      "followers_url": "https://api.github.com/users/dsander/followers",
27
+      "following_url": "https://api.github.com/users/dsander/following{/other_user}",
28
+      "gists_url": "https://api.github.com/users/dsander/gists{/gist_id}",
29
+      "starred_url": "https://api.github.com/users/dsander/starred{/owner}{/repo}",
30
+      "subscriptions_url": "https://api.github.com/users/dsander/subscriptions",
31
+      "organizations_url": "https://api.github.com/users/dsander/orgs",
32
+      "repos_url": "https://api.github.com/users/dsander/repos",
33
+      "events_url": "https://api.github.com/users/dsander/events{/privacy}",
34
+      "received_events_url": "https://api.github.com/users/dsander/received_events",
35
+      "type": "User",
36
+      "site_admin": false,
37
+      "name": "Dominik Sander",
38
+      "company": null,
39
+      "blog": "http://www.url.de",
40
+      "location": null,
41
+      "email": null,
42
+      "hireable": false,
43
+      "bio": null,
44
+      "public_repos": 29,
45
+      "public_gists": 2,
46
+      "followers": 21,
47
+      "following": 9,
48
+      "created_at": "2008-08-17T18:17:50Z",
49
+      "updated_at": "2014-05-19T09:30:08Z"
50
+    }
51
+  }
52
+}

+ 66 - 0
spec/data_fixtures/services/twitter.json

@@ -0,0 +1,66 @@
1
+{
2
+  "provider": "twitter",
3
+  "uid": "123456",
4
+  "info": {
5
+    "nickname": "johnqpublic",
6
+    "name": "John Q Public",
7
+    "location": "Anytown, USA",
8
+    "image": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png",
9
+    "description": "a very normal guy.",
10
+    "urls": {
11
+      "Website": null,
12
+      "Twitter": "https://twitter.com/johnqpublic"
13
+    }
14
+  },
15
+  "credentials": {
16
+    "token": "a1b2c3d4...",
17
+    "secret": "abcdef1234"
18
+  },
19
+  "extra": {
20
+    "access_token": "",
21
+    "raw_info": {
22
+      "name": "John Q Public",
23
+      "listed_count": 0,
24
+      "profile_sidebar_border_color": "181A1E",
25
+      "url": null,
26
+      "lang": "en",
27
+      "statuses_count": 129,
28
+      "profile_image_url": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png",
29
+      "profile_background_image_url_https": "https://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif",
30
+      "location": "Anytown, USA",
31
+      "time_zone": "Chicago",
32
+      "follow_request_sent": false,
33
+      "id": 123456,
34
+      "profile_background_tile": true,
35
+      "profile_sidebar_fill_color": "666666",
36
+      "followers_count": 1,
37
+      "default_profile_image": false,
38
+      "screen_name": "",
39
+      "following": false,
40
+      "utc_offset": -3600,
41
+      "verified": false,
42
+      "favourites_count": 0,
43
+      "profile_background_color": "1A1B1F",
44
+      "is_translator": false,
45
+      "friends_count": 1,
46
+      "notifications": false,
47
+      "geo_enabled": true,
48
+      "profile_background_image_url": "http://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif",
49
+      "protected": false,
50
+      "description": "a very normal guy.",
51
+      "profile_link_color": "2FC2EF",
52
+      "created_at": "Thu Jul 4 00:00:00 +0000 2013",
53
+      "id_str": "123456",
54
+      "profile_image_url_https": "https://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png",
55
+      "default_profile": false,
56
+      "profile_use_background_image": false,
57
+      "entities": {
58
+        "description": {
59
+          "urls": []
60
+        }
61
+      },
62
+      "profile_text_color": "666666",
63
+      "contributors_enabled": false
64
+    }
65
+  }
66
+}

+ 6 - 0
spec/fixtures/agents.yml

@@ -109,3 +109,9 @@ bob_manual_event_agent:
109 109
   user: bob
110 110
   name: "Bob's event testing agent"
111 111
   guid: <%= SecureRandom.hex %>
112
+
113
+bob_basecamp_agent:
114
+  type: Agents::BasecampAgent
115
+  user: bob
116
+  service: generic
117
+  guid: <%= SecureRandom.hex %>

+ 17 - 0
spec/fixtures/services.yml

@@ -0,0 +1,17 @@
1
+generic:
2
+  token: 1234token
3
+  secret: 56789secret
4
+  refresh_token: refresh12345
5
+  provider: testprovider
6
+  name: test
7
+  expires_at: <%= Time.parse("2015-01-01 00:00:00") %>
8
+  options: <%= { user_id: 12345 }.to_yaml.inspect %>
9
+  user: bob
10
+global:
11
+  token: 1234token
12
+  provider: testprovider
13
+  name: test
14
+  expires_at: <%= Time.parse("2015-01-01 00:00:00") %>
15
+  options: <%= { user_id: 12345 }.to_yaml.inspect %>
16
+  user: jane
17
+  global: true

+ 6 - 22
spec/models/agents/basecamp_agent_spec.rb

@@ -1,17 +1,16 @@
1 1
 require 'spec_helper'
2
+require 'models/concerns/oauthable'
2 3
 
3 4
 describe Agents::BasecampAgent do
5
+  it_behaves_like Oauthable
6
+
4 7
   before(:each) do
5 8
     stub_request(:get, /json$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
6 9
     stub_request(:get, /Z$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
7
-    @valid_params = {
8
-                      :username   => "user",
9
-                      :password   => "pass",
10
-                      :user_id    => 12345,
11
-                      :project_id => 6789,
12
-                    }
10
+    @valid_params = { :project_id => 6789 }
13 11
 
14 12
     @checker = Agents::BasecampAgent.new(:name => "somename", :options => @valid_params)
13
+    @checker.service = services(:generic)
15 14
     @checker.user = users(:jane)
16 15
     @checker.save!
17 16
   end
@@ -21,21 +20,6 @@ describe Agents::BasecampAgent do
21 20
       @checker.should be_valid
22 21
     end
23 22
 
24
-    it "should require the basecamp username" do
25
-      @checker.options['username'] = nil
26
-      @checker.should_not be_valid
27
-    end
28
-
29
-    it "should require the basecamp password" do
30
-      @checker.options['password'] = nil
31
-      @checker.should_not be_valid
32
-    end
33
-
34
-    it "should require the basecamp user_id" do
35
-      @checker.options['user_id'] = nil
36
-      @checker.should_not be_valid
37
-    end
38
-
39 23
     it "should require the basecamp project_id" do
40 24
       @checker.options['project_id'] = nil
41 25
       @checker.should_not be_valid
@@ -45,7 +29,7 @@ describe Agents::BasecampAgent do
45 29
 
46 30
   describe "helpers" do
47 31
     it "should generate a correct request options hash" do
48
-      @checker.send(:request_options).should == {:basic_auth=>{:username=>"user", :password=>"pass"}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
32
+      @checker.send(:request_options).should == {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => 'Bearer "1234token"'}}
49 33
     end
50 34
 
51 35
     it "should generate the currect request url" do

+ 1 - 0
spec/models/agents/twitter_publish_agent_spec.rb

@@ -13,6 +13,7 @@ describe Agents::TwitterPublishAgent do
13 13
     }
14 14
 
15 15
     @checker = Agents::TwitterPublishAgent.new(:name => "HuginnBot", :options => @opts)
16
+    @checker.service = services(:generic)
16 17
     @checker.user = users(:bob)
17 18
     @checker.save!
18 19
 

+ 1 - 0
spec/models/agents/twitter_stream_agent_spec.rb

@@ -13,6 +13,7 @@ describe Agents::TwitterStreamAgent do
13 13
     }
14 14
 
15 15
     @agent = Agents::TwitterStreamAgent.new(:name => "HuginnBot", :options => @opts)
16
+    @agent.service = services(:generic)
16 17
     @agent.user = users(:bob)
17 18
     @agent.save!
18 19
   end

+ 2 - 0
spec/models/agents/twitter_user_agent_spec.rb

@@ -16,6 +16,7 @@ describe Agents::TwitterUserAgent do
16 16
     }
17 17
 
18 18
     @checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => @opts)
19
+    @checker.service = services(:generic)
19 20
     @checker.user = users(:bob)
20 21
     @checker.save!
21 22
   end
@@ -31,6 +32,7 @@ describe Agents::TwitterUserAgent do
31 32
       opts = @opts.merge({ :starting_at => "Jan 01 00:00:01 +0000 2999", })
32 33
 
33 34
       checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => opts)
35
+      checker.service = services(:generic)
34 36
       checker.user = users(:bob)
35 37
       checker.save!
36 38
 

+ 29 - 0
spec/models/concerns/oauthable.rb

@@ -0,0 +1,29 @@
1
+require 'spec_helper'
2
+
3
+module Agents
4
+  class OauthableTestAgent < Agent
5
+    include Oauthable
6
+  end
7
+end
8
+
9
+shared_examples_for Oauthable do
10
+  before(:each) do
11
+    @agent = described_class.new(:name => "somename")
12
+    @agent.user = users(:jane)
13
+  end
14
+
15
+  it "should be oauthable" do
16
+    @agent.oauthable?.should == true
17
+  end
18
+
19
+  describe "valid_services" do
20
+    it "should return all available services without specifying valid_oauth_providers" do
21
+      @agent = Agents::OauthableTestAgent.new
22
+      @agent.valid_services(users(:bob)).collect(&:id).sort.should == [services(:generic), services(:global)].collect(&:id).sort
23
+    end
24
+
25
+    it "should filter the services based on the agent defaults" do
26
+      @agent.valid_services(users(:bob)).to_a.should == Service.where(provider: @agent.valid_oauth_providers)
27
+    end
28
+  end
29
+end

+ 55 - 0
spec/models/scenario_import_spec.rb

@@ -45,6 +45,18 @@ describe ScenarioImport do
45 45
       :options => trigger_agent_options
46 46
     }
47 47
   end
48
+  let(:valid_parsed_basecamp_agent_data) do
49
+    {
50
+      :type => "Agents::BasecampAgent",
51
+      :name => "Basecamp test",
52
+      :schedule => "every_2m",
53
+      :keep_events_for => 0,
54
+      :propagate_immediately => true,
55
+      :disabled => false,
56
+      :guid => "a-basecamp-agent",
57
+      :options => {project_id: 12345}
58
+    }
59
+  end
48 60
   let(:valid_parsed_data) do
49 61
     { 
50 62
       :name => name,
@@ -407,5 +419,48 @@ describe ScenarioImport do
407 419
         end
408 420
       end
409 421
     end
422
+
423
+    context "agents which require a service" do
424
+      let(:valid_parsed_services) do
425
+        data = valid_parsed_data
426
+        data[:agents] = [valid_parsed_basecamp_agent_data,
427
+                         valid_parsed_trigger_agent_data]
428
+        data
429
+      end
430
+
431
+      let(:valid_parsed_services_data) { valid_parsed_services.to_json }
432
+
433
+      let(:services_scenario_import) {
434
+        _import = ScenarioImport.new(:data => valid_parsed_services_data)
435
+        _import.set_user users(:bob)
436
+        _import
437
+      }
438
+
439
+      describe "#generate_diff" do
440
+        it "should check if the agent requires a service" do
441
+          agent_diffs = services_scenario_import.agent_diffs
442
+          basecamp_agent_diff = agent_diffs[0]
443
+          basecamp_agent_diff.requires_service?.should == true
444
+        end
445
+
446
+        it "should add an error when no service is selected" do
447
+          services_scenario_import.import.should == false
448
+          services_scenario_import.errors[:base].length.should == 1
449
+        end
450
+      end
451
+
452
+      describe "#import" do
453
+        it "should import" do
454
+          services_scenario_import.merges = {
455
+            "0" => {
456
+              "service_id" => "0",
457
+            }
458
+          }
459
+          lambda {
460
+            services_scenario_import.import.should == true
461
+          }.should change { users(:bob).agents.count }.by(2)
462
+        end
463
+      end
464
+    end
410 465
   end
411 466
 end

+ 109 - 0
spec/models/service_spec.rb

@@ -0,0 +1,109 @@
1
+require 'spec_helper'
2
+
3
+describe Service do
4
+  before(:each) do
5
+    @user = users(:bob)
6
+  end
7
+
8
+  it "should toggle the global flag" do
9
+    @service = services(:generic)
10
+    @service.global.should == false
11
+    @service.toggle_availability!
12
+    @service.global.should == true
13
+    @service.toggle_availability!
14
+    @service.global.should == false
15
+  end
16
+
17
+  it "disables all agents before beeing destroyed" do
18
+    agent = agents(:bob_basecamp_agent)
19
+    service = agent.service
20
+    service.destroy
21
+    agent.reload
22
+    agent.service_id.should be_nil
23
+    agent.disabled.should be_true
24
+  end
25
+
26
+  describe "preparing for a request" do
27
+    before(:each) do
28
+      @service = services(:generic)
29
+    end
30
+
31
+    it "should not update the token if the token never expires" do
32
+      @service.expires_at = nil
33
+      @service.prepare_request.should == nil
34
+    end
35
+
36
+    it "should not update the token if the token is still valid" do
37
+      @service.expires_at = Time.now + 1.hour
38
+      @service.prepare_request.should == nil
39
+    end
40
+
41
+    it "should call refresh_token! if the token expired" do
42
+      stub(@service).refresh_token! { @service }
43
+      @service.expires_at = Time.now - 1.hour
44
+      @service.prepare_request.should == @service
45
+    end
46
+  end
47
+
48
+  describe "updating the access token" do
49
+    before(:each) do
50
+      @service = services(:generic)
51
+    end
52
+
53
+    it "should return the correct endpoint" do
54
+      @service.provider = '37signals'
55
+      @service.send(:endpoint).to_s.should == "https://launchpad.37signals.com/authorization/token"
56
+    end
57
+
58
+    it "should update the token" do
59
+      stub_request(:post, "https://launchpad.37signals.com/authorization/token?client_id=TESTKEY&client_secret=TESTSECRET&refresh_token=refreshtokentest&type=refresh").
60
+        to_return(:status => 200, :body => '{"expires_in":1209600,"access_token": "NEWTOKEN"}', :headers => {})
61
+      @service.provider = '37signals'
62
+      ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'] = 'TESTKEY'
63
+      ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'] = 'TESTSECRET'
64
+      @service.refresh_token = 'refreshtokentest'
65
+      @service.refresh_token!
66
+      @service.token.should == 'NEWTOKEN'
67
+    end
68
+  end
69
+
70
+  describe "creating services via omniauth" do
71
+    it "should work with twitter services" do
72
+      twitter = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json')))
73
+      expect {
74
+        service = @user.services.initialize_or_update_via_omniauth(twitter)
75
+        service.save!
76
+      }.to change { @user.services.count }.by(1)
77
+      service = @user.services.first
78
+      service.name.should == 'johnqpublic'
79
+      service.provider.should == 'twitter'
80
+      service.token.should == 'a1b2c3d4...'
81
+      service.secret.should == 'abcdef1234'
82
+    end
83
+    it "should work with 37signals services" do
84
+      signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/37signals.json')))
85
+      expect {
86
+        service = @user.services.initialize_or_update_via_omniauth(signals)
87
+        service.save!
88
+      }.to change { @user.services.count }.by(1)
89
+      service = @user.services.first
90
+      service.provider.should == '37signals'
91
+      service.name.should == 'Dominik Sander'
92
+      service.token.should == 'abcde'
93
+      service.refresh_token.should == 'fghrefresh'
94
+      service.options[:user_id].should == 12345
95
+      service.expires_at = Time.at(1401554352)
96
+    end
97
+    it "should work with github services" do
98
+      signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/github.json')))
99
+      expect {
100
+        service = @user.services.initialize_or_update_via_omniauth(signals)
101
+        service.save!
102
+      }.to change { @user.services.count }.by(1)
103
+      service = @user.services.first
104
+      service.provider.should == 'github'
105
+      service.name.should == 'dsander'
106
+      service.token.should == 'agithubtoken'
107
+    end
108
+  end
109
+end

+ 2 - 0
spec/spec_helper.rb

@@ -21,6 +21,8 @@ WebMock.disable_net_connect!
21 21
 # in spec/support/ and its subdirectories.
22 22
 Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f}
23 23
 
24
+ActiveRecord::Migration.maintain_test_schema!
25
+
24 26
 RSpec.configure do |config|
25 27
   config.mock_with :rr
26 28