You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ode.apache.org by as...@apache.org on 2007/11/22 08:54:13 UTC

svn commit: r597323 - in /ode/sandbox/singleshot: ./ app/controllers/ app/helpers/ app/models/ config/ db/migrate/ doc/pages/ lib/extensions/ lib/tasks/ public/javascripts/ public/stylesheets/ spec/ spec/controllers/ spec/models/

Author: assaf
Date: Wed Nov 21 23:54:08 2007
New Revision: 597323

URL: http://svn.apache.org/viewvc?rev=597323&view=rev
Log:
Added more fields to Task, initial TasksController and test cases

Added:
    ode/sandbox/singleshot/doc/pages/managing_tasks.textile
    ode/sandbox/singleshot/doc/pages/security.textile
    ode/sandbox/singleshot/lib/extensions/json_request.rb
    ode/sandbox/singleshot/lib/extensions/to_xml_primitive.rb
    ode/sandbox/singleshot/lib/extensions/validators.rb
    ode/sandbox/singleshot/lib/tasks/database.rake
    ode/sandbox/singleshot/spec/common.rb
    ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb
Removed:
    ode/sandbox/singleshot/doc/pages/authentication.textile
Modified:
    ode/sandbox/singleshot/NOTICE
    ode/sandbox/singleshot/README
    ode/sandbox/singleshot/app/controllers/application.rb
    ode/sandbox/singleshot/app/controllers/sessions_controller.rb
    ode/sandbox/singleshot/app/controllers/tasks_controller.rb
    ode/sandbox/singleshot/app/helpers/application_helper.rb
    ode/sandbox/singleshot/app/models/person.rb
    ode/sandbox/singleshot/app/models/stakeholder.rb
    ode/sandbox/singleshot/app/models/task.rb
    ode/sandbox/singleshot/config/environment.rb
    ode/sandbox/singleshot/config/routes.rb
    ode/sandbox/singleshot/db/migrate/002_create_tasks.rb
    ode/sandbox/singleshot/public/javascripts/application.js
    ode/sandbox/singleshot/public/stylesheets/default.css
    ode/sandbox/singleshot/spec/controllers/sessions_controller_spec.rb
    ode/sandbox/singleshot/spec/enhancements.rb
    ode/sandbox/singleshot/spec/models/task_spec.rb
    ode/sandbox/singleshot/spec/spec_helper.rb

Modified: ode/sandbox/singleshot/NOTICE
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/NOTICE?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/NOTICE (original)
+++ ode/sandbox/singleshot/NOTICE Wed Nov 21 23:54:08 2007
@@ -1,7 +1,13 @@
-Rails, copyright of David Heinemeier Hansson, released under the MIT license.
+This software includes code and material from the following sources:
 
-Annotate Models, copyright of Dave Thomas, Pragmatic Programmers, LLC, released under the Ruby license.
+Rails, copyright of David Heinemeier Hansson, distributed under the MIT license.
 
-Exception Notifier, copyright of Jamis Buck, released under the MIT license
+Prototype, copyright of Sam Stephenson, distributed under the MIT license.
 
-RSpec, copyright of The RSpec Development Team, released under the MIT license.
+script.aculo.us, copyright of Thomas Fuchs, Ivan Krstic, Jon Tirsen and Sammi Williams, distributed under the MIT license.
+
+Annotate Models, copyright of Dave Thomas, Pragmatic Programmers, LLC, distributed under the Ruby license.
+
+Exception Notifier, copyright of Jamis Buck, distributed under the MIT license
+
+RSpec, copyright of The RSpec Development Team, distributed under the MIT license.

Modified: ode/sandbox/singleshot/README
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/README?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/README (original)
+++ ode/sandbox/singleshot/README Wed Nov 21 23:54:08 2007
@@ -35,5 +35,40 @@
 
   $ ./script/console
   >> Person.create :email=>'assaf@apache.org', :password=>'secret'
-  => #<Person id: 1, nickname: "assaf", fullname: "Assaf", email: "assaf@apache.org", language: nil, timezone: nil, password: "$2a$10$ncrCapBySVOw737CD9EnK.XEEFd1hx4v6SVbGQpd85K0...", access_key: "668041114980ae1b3bffda84d6e036e9", site_url: nil, created_at: "2007-11-19 12:40:18", updated_at: "2007-11-19 12:40:18">
+  => #<Person id: 1, nickname: "assaf", fullname: "Assaf", email: "assaf@apache.org", language: nil, timezone: nil, password: "688f750858:80fbb9e9817e5638c4ceaba55ab69760276231af...", access_key: "668041114980ae1b3bffda84d6e036e9", site_url: nil, created_at: "2007-11-19 12:40:18", updated_at: "2007-11-19 12:40:18">
 
+
+== Migrations
+
+Rails migrations are a great way to progress from one database schema version
+to another, specifically when moving between milestones and major releases.
+They are a pain when used during the 'research' part.
+
+Each major milestone or version will freeze on the current database schema, so
+all future changes to the schema will require additional migrations.  However,
+work ongoing in trunk should edit existing migrations, which means the
+occassional need to run db:migrate up and down when following development.
+
+If you are writing a migration, make sure to test that it works both ways, test
+it against the code, and then run:
+  $ rake annotate_models
+
+This task updates the comment in the beginning of each model file to reflect
+the most recent database schema.
+
+
+== Getting Involved
+
+The development model is explain->spec->code.
+
+Everything starts with an explanation, which eventually will turn into the
+official documentation.  You can find those in the doc/pages directory.
+The file format is Textile.
+
+Specifications are written using RSpec.  MVC, plugins and other libraries are
+speced in the various sub-directories of specs.  User stories are speced in
+the stories directory.
+
+The code lives mostly under app, which generic code residing in lib, and
+occassionally extracted into plugins under vendor/plugins.  3rd party plugins
+are also there.

Modified: ode/sandbox/singleshot/app/controllers/application.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/controllers/application.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/controllers/application.rb (original)
+++ ode/sandbox/singleshot/app/controllers/application.rb Wed Nov 21 23:54:08 2007
@@ -7,7 +7,7 @@
 
   # Turn sessions off for JSON, XML, Atom, etc.  We only use sessions for login/flash in HTML pages
   # and XHR requests.
-  session :if=>lambda { |req| !(req.format.html? || req.xhr?) }
+  session :off, :if=>lambda { |req| !(req.format.html? || req.xhr?) }
 
 
   # --- Authentication ---
@@ -16,27 +16,18 @@
   attr_reader :authenticated
 
   def authenticate
-    if request.format.html?
-      authenticate_using_session
+    # Always allow HTTP Basic Authentication, regardless of the format, but
+    # use HTTP Basic in lieu of sessions (i.e. JSON or XML request formats).
+    if ActionController::HttpAuthentication::Basic.authorization(request) || !session_enabled?
+      authenticate_or_request_with_http_basic 'Singleshot' do |login, password|
+        @authenticated = Person.authenticate(login, password)
+      end
     else
-      authenticate_using_http_basic
-    end
-  end
-
-  # Use HTTP Basic authentication.
-  def authenticate_using_http_basic
-    authenticate_or_request_with_http_basic do |login, password|
-      @authenticated = Person.authenticate(login, password)
-    end
-  end
-
-  # Use session-based authentication.  If not currently authenticated in session,
-  # redirects to session_url.  Set person_id on login, reset on logout.
-  def authenticate_using_session
-    @authenticated = Person.find_by_id(session[:person_id])
-    unless @authenticated
-      flash[:follow] = request.url
-      redirect_to(session_url)
+      @authenticated = Person.find(session[:person_id]) rescue nil
+      unless @authenticated
+        flash[:return_to] = request.url
+        redirect_to session_url
+      end
     end
   end
 
@@ -45,7 +36,7 @@
   # This authentication only works for GET requests.
   def authenticate_using_access_key
     @authenticated = Person.find_by_access_key(params[:access_key]) if request.get?
-    head :not_found unless @authenticated
+    raise ActiveRecord::RecordNotFound unless @authenticated
   end
 
   # Returns query string parameters for authentication, see #authenticate_using_access_key.

Modified: ode/sandbox/singleshot/app/controllers/sessions_controller.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/controllers/sessions_controller.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/controllers/sessions_controller.rb (original)
+++ ode/sandbox/singleshot/app/controllers/sessions_controller.rb Wed Nov 21 23:54:08 2007
@@ -1,16 +1,17 @@
 class SessionsController < ApplicationController
 
   def show
-    flash.keep(:follow)
+    flash.keep(:return_to)
   end
 
   def create
-    if person = Person.authenticate(params[:login], params[:password])
+    login, password = params.values_at(:login, :password)
+    if person = Person.authenticate(login, password)
       session[:person_id] = person.id
-      redirect_to flash[:follow] || root_url, :status=>:see_other 
+      redirect_to flash[:return_to] || root_url, :status=>:see_other 
     else
-      flash.keep(:follow)
-      flash[:error] = 'No account with this login and password.'
+      flash.keep(:return_to)
+      flash[:error] = 'No account with this login and password.' unless login.blank?
       redirect_to session_url, :status=>:see_other
     end
   end

Modified: ode/sandbox/singleshot/app/controllers/tasks_controller.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/controllers/tasks_controller.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/controllers/tasks_controller.rb (original)
+++ ode/sandbox/singleshot/app/controllers/tasks_controller.rb Wed Nov 21 23:54:08 2007
@@ -1,3 +1,27 @@
 class TasksController < ApplicationController
 
+  before_filter :authenticate
+  before_filter :find_task, :only=>[:show]
+
+  def new
+    @task = Task.new(:creator=>authenticated)
+    respond_to :html
+  end
+
+  def create
+    @task = Task.create(params[:task])
+    respond_to do |format|
+      format.html { redirect_to tasks_url }
+      format.xml  { render :xml=>@task, :location=>task_url(@task), :status=>:created }
+      format.json { render :json=>@task, :location=>task_url(@task), :status=>:created }
+    end
+  end
+
+private
+
+  def find_task
+    @task = Task.find(params[:id])
+    raise ActiveRecord::RecordNotFound unless @task.stakeholder?(authenticated)
+  end
+
 end

Modified: ode/sandbox/singleshot/app/helpers/application_helper.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/helpers/application_helper.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/helpers/application_helper.rb (original)
+++ ode/sandbox/singleshot/app/helpers/application_helper.rb Wed Nov 21 23:54:08 2007
@@ -10,4 +10,7 @@
       content_tag('span', h(person.fullname), options)
   end
 
+  # Returns Person object for currently authenticated user.
+  attr_reader :authenticated
+
 end

Modified: ode/sandbox/singleshot/app/models/person.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/models/person.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/models/person.rb (original)
+++ ode/sandbox/singleshot/app/models/person.rb Wed Nov 21 23:54:08 2007
@@ -35,13 +35,17 @@
 
   class << self
 
-    # Resolves a person based on their identity, throws RecordNotFound if no such person can
-    # be found.  For convenience, when called with a Person object, returns that same object.
-    # Matches against the identity returned in to_param.
-    def identify(identity)
-      return identity if Person === identity
-      Person.find_by_nickname(identity) or
-        raise ActiveRecord::RecordNotFound, "Cannot find '#{identity}'"
+    # Resolves a person based on their identity.  For convenience, when called with a Person object,
+    # returns that same object. You can also call this method with an array of identities, and
+    # it will return an array of people.  Matches against the identity returned in to_param.
+    def identify(*args)
+      identities = args.flatten
+      if identities.size > 1
+        identities.map { |identity| identify(identity) }.uniq 
+      else
+        identity = identities.first
+        Person === identity ? identity : Person.find_by_nickname(identity)
+      end
     end
 
     # Used for identity/password authentication.  Return the person if authenticated.
@@ -76,11 +80,14 @@
   end
 
   # Must have e-mail address.
-  validates_format_of     :email, :with => /^([^@\s]+)@[-a-z0-9]+(\.[-a-z0-9]+)*$/, :message=>"I need a valid e-mail address."
+  validates_email         :email, :message=>"I need a valid e-mail address."
   validates_uniqueness_of :email, :message=>'This e-mail is already in use.'
   before_validation do |record|
     record.email = record.email.to_s.strip.downcase
   end
+
+  # Site URL, if specified, must be a valid HTTP(S) URL.
+  validates_url :site_url, :if=>:site_url
 
   # TODO:  Some way to check minimum size of passwords.
 

Modified: ode/sandbox/singleshot/app/models/stakeholder.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/models/stakeholder.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/models/stakeholder.rb (original)
+++ ode/sandbox/singleshot/app/models/stakeholder.rb Wed Nov 21 23:54:08 2007
@@ -22,11 +22,12 @@
     # * :creator         -- Person who created the task, specified at creation.
     # * :owner           -- Person who currently owns (performs) the task.
     # * :potential_owner -- Person who is allowed to claim (become owner of) the task.
+    # * :excluded_owner  -- Person who is not allowed to claim the task.
     # * :admin           -- Admins are allowed to modify the task, change its status, etc.
     #
     # Role is stored as index value, but presented elsewhere as symbol, so when adding new roles,
     # make sure to append but not to remove or change the order of roles in this list.
-    ROLES = [:creator, :owner, :potential_owner, :admin]
+    ROLES = [:creator, :owner, :potential_owner, :excluded_owner, :admin]
 
     # The following roles allow one stakeholder per task: creator, owner.
     SINGULAR_ROLES = [:creator, :owner]

Modified: ode/sandbox/singleshot/app/models/task.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/models/task.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/models/task.rb (original)
+++ ode/sandbox/singleshot/app/models/task.rb Wed Nov 21 23:54:08 2007
@@ -5,6 +5,8 @@
 #
 #  id                :integer(11)   not null, primary key
 #  title             :string(255)   default(""), not null
+#  priority          :integer(1)    default(1), not null
+#  due_on            :date          
 #  status            :integer(2)    default(0), not null
 #  completion_url    :string(255)   
 #  completion_format :string(255)   
@@ -23,6 +25,8 @@
   # Locking column used for versioning and detecting update conflicts.
   set_locking_column 'version'
 
+  PRIORITIES = 1..5
+
   def initialize(*args)
     super
     self.status = :active
@@ -40,18 +44,31 @@
   validates_presence_of :status
   attr_protected :status
 
+  validates_inclusion_of :priority, :in=>PRIORITIES
+
+  def priority
+    self[:priority] ||= PRIORITIES.min
+  end
+
+
+  # -- View and perform ---
+
+  validates_presence_of :perform_url, :message=>'Perform URL required'
+  validates_url :perform_url
+  validates_url :view_url, :if=>:view_url
+
 
   # --- Completion and cancellation ---
 
   # Supported formats for sending completion notification.
   COMPLETION_FORMATS = [Mime::JSON, Mime::XML]
 
-  #normalize_urls :completion_url
-  #validates_http_url :completion_url,
-  #  :message=>"All tasks must have a valid completion URL (HTTPS or HTTP)"
   validates_inclusion_of :completion_format, :in=>COMPLETION_FORMATS,
     :message=>"Supported completion formats are #{COMPLETION_FORMATS.to_sentence}"
 
+  validates_presence_of :completion_url, :message=>'Completion URL required'
+  validates_url :completion_url
+
   # Returns the completion format as a Mime type.
   def completion_format
     Mime::EXTENSION_LOOKUP[self[:completion_format]] || Mime::XML
@@ -59,7 +76,16 @@
 
   # Sets the completion format using a Mime type.
   def completion_format=(content_type)
-    self[:completion_format] = content_type.to_sym.to_s
+    case content_type
+    when Mime::Type     # Mime::JSON
+      self[:completion_format] = content_type.to_sym.to_s
+    when /\//           # application/json
+      self[:completion_format] = Mime::LOOKUP[content_type].to_sym.to_s
+    when Symbol, String # json, JSON, :json
+      self[:completion_format] = content_type.to_s.downcase
+    else
+      raise ArgumentError, 'Expecting content type, extension or Mime::Type'
+    end
   end
 
 
@@ -89,6 +115,20 @@
   has_many :stakeholders, :include=>:person, :dependent=>:delete_all
   has_many :people, :through=>:stakeholders
  
+  class << self
+
+    # Returns list of administrators associated with all tasks.  These are not stored along with
+    # the task, but has the same role as admin stakeholder in each task.
+    def admins
+      @admins ||= []
+    end
+
+    # Sets list of administrators associated with all tasks.
+    def admins=(admins)
+      @admins = Array(admins)
+    end
+  end
+
   # :call-seq:
   #    in_role(*roles) => Person*
   #    in_role(:any) => Person*
@@ -103,57 +143,93 @@
       stakeholders.select { |sh| roles.include?(sh.role) }.map { |sh| sh.person }
     end
   end
+  private :in_role
 
   # :call-seq:
-  #   in_role?(identity, *roles) => boolean
+  #   in_role?(person, *roles) => boolean
   #
   # Returns true if given person is associated with this task in a particular role.
   # You can also query against a set of roles, or any roles by passing :any.
-  def in_role?(identity, *roles)
-    in_role(roles).include?(Person.identify(identity))
+  def in_role?(person, *roles)
+    in_role(roles).include?(person)
+  end
+  private :in_role?
+
+  # :call-seq:
+  #   roles(person) => *roles
+  #
+  # Returns all the roles this person is associated with.
+  def roles(person)
+    stakeholders.select { |sh| sh.person == person }.map { |sh| sh.role }.uniq
   end
+  private :roles
 
   # Adds methods for singular roles, like this:
   # * owner?(identity) -- Returns true if person is in this role
-  # * owner            -- Returns person in this role
+  # * owner            -- Returns identity of person in this role
   # * owner = identity -- Assigns a person to this role
   SINGULAR_ROLES.each do |role|
     define_method("#{role}?") { |identity| in_role?(Person.identify(identity), role) }
     define_method(role) { in_role(role).first }
     define_method "#{role}=" do |identity|
       current = stakeholders.detect { |sh| sh.role == role }
-      if identity
-        person = Person.identify(identity)
+      if !identity.blank? && person = identify_or_new(identity)
         if current.nil? || person != current.person
           stakeholders.delete current if current
           stakeholders.build :person=>person, :role=>role
         end
-      elsif current
-        stakeholders.delete current
+      else
+        stakeholders.delete current if current
       end
     end
+  end
 
-    validate do |record|
-      record.errors.add role, 'A task can have only one #{role}' if record.in_role(role).size > 1
+  validate do |record|
+    record.instance_eval do
+      SINGULAR_ROLES.each do |role|
+        errors.add role, 'A task can have only one #{role}' if in_role(role).size > 1
+      end
     end
   end
 
   # Adds methods for plural roles, like this:
   # * admin?(identity)    -- Returns true if person is in this role
-  # * admins              -- Returns all people in this role
+  # * admins              -- Returns identity of all people in this role
   # * admins = identities -- Assigns person/people to this role
   PLURAL_ROLES.each do |role|
     plural = role.to_s.pluralize
     define_method("#{role}?") { |identity| in_role?(Person.identify(identity), role) }
     define_method(plural) { in_role(role) }
     define_method "#{plural}=" do |identities|
-      new_set = Array(identities).map { |identity| Person.identify(identity) }.uniq
+      new_set = Array(identities).map { |identity| identify_or_new(identity) }.uniq
       existing = stakeholders.select { |sh| sh.role == role }
       stakeholders.delete existing.reject { |sh| new_set.include?(sh.person) }
       (new_set - existing.map { |sh| sh.person }).each { |person| stakeholders.build :person=>person, :role=>role }
     end
   end
 
+  # Returns true if person is a stakeholder in this task (equivalent to asking if in any role).
+  def stakeholder?(person)
+    stakeholders.any? { |sh| sh.person == person } || Task.admins.include?(person)
+  end
+
+  # Slightly different from Person.identify.  If Person.identify does not find the person,
+  # it returns nil, so setting that person would fail silently which is not what we want.
+  # RecordNotFound exception is also bad, unless we pay attention, we'll end up sending
+  # a 404 to the client.  So this method creates a new record in memory, which we're going
+  # to detect during validation and fail with a proper error message.
+  def identify_or_new(person)
+    Person.identify(person) || Person.new(:nickname=>person)
+  end
+  private :identify_or_new
+
+  validate do |record|
+    record.stakeholders.each do |stakeholder|
+      person = stakeholder.person
+      record.errors.add :stakeholders, "Cannot find person with identity '#{person.to_param}' for the role #{stakeholder.role}" if person.new_record?
+    end
+  end
+
 
   # --- Access control ---
 
@@ -163,7 +239,7 @@
   # The token is validated by calling #authorize.  The token is only valid
   # if the person is a stakeholder in the task, and based on their role.
   def token_for(person)
-    MD5.hexdigest("#{person.to_param}:#{access_key}")
+    OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, access_key, person.to_param)
   end
 
   # Returns the person authorized to access this task based on the token returned

Modified: ode/sandbox/singleshot/config/environment.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/config/environment.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/config/environment.rb (original)
+++ ode/sandbox/singleshot/config/environment.rb Wed Nov 21 23:54:08 2007
@@ -31,7 +31,7 @@
   # If you change this key, all old sessions will become invalid!
   config.action_controller.session = {
     :session_key => '_single_shot_session',
-    :secret      => %{37°20'12.78"N, 121°53'41.69"W}
+    :secret      => %{ 37°20'12.78"N, 121°53'41.69"W}
   }
 
   # Use the database for sessions instead of the file system

Modified: ode/sandbox/singleshot/config/routes.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/config/routes.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/config/routes.rb (original)
+++ ode/sandbox/singleshot/config/routes.rb Wed Nov 21 23:54:08 2007
@@ -1,6 +1,8 @@
 ActionController::Routing::Routes.draw do |map|
 
   map.resource :session
+  map.resources :tasks
+  map.connect '/tasks/:id', :controller=>'tasks', :action=>'complete', :conditions=>{ :method=>:post }
   map.root :controller=>'tasks'
 
   # Authentication resource (create/destroy).

Modified: ode/sandbox/singleshot/db/migrate/002_create_tasks.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/db/migrate/002_create_tasks.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/db/migrate/002_create_tasks.rb (original)
+++ ode/sandbox/singleshot/db/migrate/002_create_tasks.rb Wed Nov 21 23:54:08 2007
@@ -2,7 +2,11 @@
   def self.up
     create_table :tasks do |t|
       t.string    :title,             :null=>false
+      t.integer   :priority,          :null=>false, :default=>1, :limit=>1
+      t.date      :due_on,            :null=>true
       t.integer   :status,            :null=>false, :default=>0, :limit=>2
+      t.string    :perform_url,       :null=>false
+      t.string    :view_url,          :null=>true
       t.string    :completion_url,    :null=>true
       t.string    :completion_format, :null=>true
       t.string    :access_key,        :null=>true, :limit=>32

Added: ode/sandbox/singleshot/doc/pages/managing_tasks.textile
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/doc/pages/managing_tasks.textile?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/doc/pages/managing_tasks.textile (added)
+++ ode/sandbox/singleshot/doc/pages/managing_tasks.textile Wed Nov 21 23:54:08 2007
@@ -0,0 +1,70 @@
+h1.  Creating and Managing Tasks
+
+h2.  Creating a Task
+
+To create a task, the application authenticates and makes a POST request against the resource @/tasks@.  If successful, the task manager returns the status code 201 (Created), a representation of the task in the specified content type, and sets the @Location@ header to the URL of the new task resource.
+
+The application can make the request in any of the supported content types (see "Request Content Types":#Request-Content-Types), using the following fields:
+
+* @title@ --The task title as it will show up in the tasks list.  The task title is assumed to be text, UTF-8 accepted.
+* @completion_url@ -- When the task completes, the task manager notifies the application by making a POST request to this URL.  You can use HTTP or HTTPS (recommended), and can include authority information for HTTP authentication.
+* @completion_format@ -- The completion notification is sent using either @application/xml@ or @application/json@ content types.  The task manager can infer the completion format from the request content type, defaulting to @application/xml@ for all other content types.  Setting this field forces a particular content type.
+* @perform_url@ -- A URL used for performing the task (see Presenting Tasks).
+* @view_url@ -- A URL used for viewing the task (see Presenting Tasks), only necessary if different form the URL used for performing the task.
+* @priority@ -- The task priority as a value from 1 to 5, 5 being the highest priority.
+* name/tag/labels
+* @due_on@ -- If the task has a due-on date, this specifies the date/time using the ISO8601 format.
+* @creator@ -- When creating the task on behalf of a person, use this to specify the creator's identity.  The creator of the task is separate from the account used to create the task, the later typically belonging to the application.
+* @owner@ -- Specify this to immediately assign the task to a particular person.  Otherwise, the task is left available and any potential owner can claim it.
+* @potential_owners@ -- List of potential owners that can claim the task.
+* @excluded_owners@ -- List of people who are not allowed to claim the task, typically owners of previously completed tasks.  People listed as both potential and excluded owners are not allowed to claim the task.
+* @admins@ -- List of administrators associated with the task.  You can use this to associate specific administrators on a task, beyond those allowed to administer all tasks.  The account used to create the task is automatically added as an administrator on the task.
+* @data@ -- Each task can hold an arbitrary set of name/value pairs which are passed between the application and task manager, made available when viewing/performing the task, and passed back to the application on completion.
+
+When specifying a singular stakeholder (e.g. creator, owner), use the form of identity associated with that person, currently their nickname.  When specifying a plural stakeholder (e.g. potential owners, admins), use an array of identities.  Either way, the person referenced as a stakeholder must exist in the system with that identity.
+
+In JSON, the task data is represented as a hash.  In XML, as an element called @data@, with each name/value pair represented as an element of that name and value as the element content.  In URL encoded and multi-part, using the notation for hashs.  For example:
+
+  task {
+    data: {
+      order: 123,
+      status: "open"s
+    }
+  }
+
+  <task>
+    <data>
+      <order type='integer'>123</order>
+      <status>open</status>
+    </data>
+  </task>
+
+  task[data][order]=123&task[data][status]=open
+
+
+h2.  Request Content Types
+
+Singleshot supports requests in four content types: JSON, XML, URL encoded and multi-part.
+
+*application/json*  The request is formatted as a JSON object, using arrays and primitive types where applicable.  Dates should be formatted as ISO-8601. 
+
+*application/xml*  The request is formatted as an XML document using the applicable document element, for example, @<task>@ to represent a task.  Element names use dashes instead of underscore, for example, @due_on@ becomes @<due-on>@.  The value of each field comes from the element content, and the @type@ attribute specifies its primitive type.  The following primitive types are supported:
+
+* @date@ -- Date instance.
+* @datetime@ -- Date/time instance, using the XML Schema format (a restricted form of ISO-8601).
+* @integer@ -- Integer value.
+* @float@ -- Numeric float.
+* @boolean@ -- Either @true@ or @false@.
+* @string@ -- Not using the @type@ attribute is also interpreted as a string.
+* @base64Binary@ -- Base-64 encoded binary content.
+
+Arrays are specified using a plural element with the type @array@, containing one singular element for each item, for example:
+
+  <potential-owners type='array'>
+    <potential-owner>assaf</potential-owner>
+    <potential-owner>alex</potential-owner>
+  </potential-owners>
+
+*application/x-www-form-urlencoded* and *multipart/form-data*  Field names use a particular notation that maps into hashes and array.  For example, the priority field of a task is named @task[priority]@.  For arrays, use multiple fields with square brackets as the suffic, for example, @task[stake_holders]@.
+
+UTF-8 encoding is supported for all content types.

Added: ode/sandbox/singleshot/doc/pages/security.textile
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/doc/pages/security.textile?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/doc/pages/security.textile (added)
+++ ode/sandbox/singleshot/doc/pages/security.textile Wed Nov 21 23:54:08 2007
@@ -0,0 +1,46 @@
+h1.  Access Control and Security
+
+This section deals with people, authentication, authorization and all other manners of security.
+
+
+h2.  People
+
+Every person is represented with a record in the database (the @persons@ table).  The minimum information we need to know about each person is their identity (lookup and reference), full name (display), e-mail address (notifications), preferred language and current timezone.
+
+The implementation uses nicknames for identity.  You can change the implementation to use other forms of identity (e.g. OpenID URLs) by changing @Person.identify@ and @Person.to_param@ respectively.  It also uses nicknames as login names that in combination with passwords are used for HTTP Basic and session authentication.  You can change this behavior or implement other authentication mechanisms by changing @Person.authenticate@.
+
+The last piece of information we use is the site_url, which if specified will link to that person's HTML page.
+
+Storing information about people externally (e.g. LDAP directory, OpenID server) is possible with the following constraints.  You can replace the way @Person@ retrieves, updates and authenticates people in the system, but you need to maintain at least a skeleton record referencing those external resource.  Other records reference people internally through their primary key.
+
+
+h2.  Authentication
+
+Singleshot requires at least four different methods of authentication, depending on the type of access.  Username/password authentication (HTTP Basic and Sessions) is built into the system, but doesn't preclude the use of external mechanisms such as Single Sign-on, OpenID and OAuth.
+
+*HTTP Basic*  The REST API uses HTTP Basic Authentication.  HTTP Basic is generally easier to use and more widely supported than HTTP Digest.  Passing sensitive information in clear text is a concern but not a reason for using HTTP Digest; use HTTPS to secure all information transmitted over the wire.
+
+Passwords are salted and stored as SHA1 digests of the password/salt combination, along with the salt.
+
+*Sessions*  HTTP Authentication is not particularly user friendly.  The presentation of the authentication dialog box is inconsistent with the rest of the application, and provides no relevant links for registration, retrieving lost passwords or using alternative authentication mechanisms (such as OpenID).  It also does not integrate with single sign-on.
+
+Browsers authenticate through several mechanisms that eventually associate a particular user with the current session and maintain that authentication using session cookies.  The current mechanism uses login name and password.
+
+*Access key*  Feed readers and calendars can't use either scheme.  Some do allow you to use URLs that contain authentication information (username and password), but those are not implemented consistently, and further risks exposing your credentials.  Instead we use an access key generated for each account.  This access key is used as a query string parameter to grant read-only (GET) access to the resource.
+
+Resources that support the access key create their URLs using @access_key_authentication_parameters@ and authenticate using @authenticate_using_access_key@.  To deal with compromised access keys, reset the person's access key by calling @Person.access_key!@.
+
+*Tokens*  To view and perform the task, the application also needs to access the task state.  Again we have no way of passing credentials around, and instead use unique tokens to grant access to the individual task and stakeholder. 
+
+*OpenID and OAuth*  TBD.
+
+*Single Sign-on*  TBD.
+
+
+h2.  Authorization
+
+A task is associated with stakeholders, stakeholders are people related to the task through a specific role.  For example, any stakeholder of the task can view it, but only the owner can perform it; potential owners are allowed to claim the task, the owner is allowed to delegate, and so forth.  Authorization is based on these stakeholders and the capabilities granted to each role.
+
+One role is that of an administrator, and each task has at least one administrator established when creating that task.  For convenience, we also maintain a global list of administrators associated with all tasks through @Task.admins@.
+
+

Added: ode/sandbox/singleshot/lib/extensions/json_request.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/lib/extensions/json_request.rb?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/lib/extensions/json_request.rb (added)
+++ ode/sandbox/singleshot/lib/extensions/json_request.rb Wed Nov 21 23:54:08 2007
@@ -0,0 +1,34 @@
+module ActionController
+  # Handles JSON requests.
+  #
+  # When receiving a request with a JSON object/array, parses the request into a hash/array.
+  # Since the request is unnamed, figures the parameter name based on the controller name.
+  # For example, for the controller ItemsController, maps a JSON object as Hash into the
+  # parameter :item, and JSON array into the parameter :items.  If you need to pick a different
+  # parameter name, override #unwrapped_parameter_name.
+  module JsonRequest
+    def self.included(mod)
+      super
+      mod.param_parsers[Mime::JSON] = lambda { |body| { :_unwrapped => ActiveSupport::JSON.decode(body) }.with_indifferent_access }
+      mod.alias_method_chain :assign_names, :unwrapped_parameter
+    end
+
+    def assign_names_with_unwrapped_parameter
+      assign_names_without_unwrapped_parameter
+      if data = params.delete('_unwrapped')
+        name = unwrapped_parameter_name(data.is_a?(Array))
+        params.update(name=>data)
+      end
+    end
+    private :assign_names_with_unwrapped_parameter
+
+    # Picks a name for the unwrapped parameter.  The default implementation uses the controller name
+    # when +plural+ is true, and controller name made singular otherwise.
+    def unwrapped_parameter_name(plural)
+      plural ? controller_name : controller_name.singularize
+    end
+    protected :unwrapped_parameter_name
+  end
+end 
+
+ActionController::Base.send :include, ActionController::JsonRequest

Added: ode/sandbox/singleshot/lib/extensions/to_xml_primitive.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/lib/extensions/to_xml_primitive.rb?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/lib/extensions/to_xml_primitive.rb (added)
+++ ode/sandbox/singleshot/lib/extensions/to_xml_primitive.rb Wed Nov 21 23:54:08 2007
@@ -0,0 +1,60 @@
+module ActiveSupport #:nodoc:
+  module CoreExtensions #:nodoc:
+    module Array #:nodoc:
+      module Conversions
+
+        def to_xml(options = {})
+          options[:root]     ||= all? { |e| e.is_a?(first.class) && first.class.to_s != "Hash" } ? first.class.to_s.underscore.pluralize : "records"
+          options[:children] ||= options[:root].singularize
+          options[:indent]   ||= 2
+
+          root     = options.delete(:root).to_s
+          children = options.delete(:children)
+
+          if !options.has_key?(:dasherize) || options[:dasherize]
+            root = root.dasherize
+          end
+
+          options[:builder].instruct! unless options.delete(:skip_instruct)
+
+          opts = options.merge({ :root => children })
+
+          xml = options[:builder]
+          if empty?
+            xml.tag!(root, options[:skip_types] ? {} : {:type => "array"})
+          else
+            xml.tag!(root, options[:skip_types] ? {} : {:type => "array"}) do
+               yield xml if block_given?
+
+              each do |value|
+                case value
+                  when ::Hash
+                    value.to_xml(opts.merge!({:skip_instruct => true }))
+                  when ::Method, ::Proc
+                    value.call(opts.merge!({ :skip_instruct => true }))
+                  else
+                    if value.respond_to?(:to_xml)
+                      value.to_xml(opts.merge!({ :skip_instruct => true }))
+                    else
+                      type_name = Hash::Conversions::XML_TYPE_NAMES[value.class.name]
+
+                      attributes = opts[:skip_types] || value.nil? || type_name.nil? ? { } : { :type => type_name }
+                      if value.nil?
+                        attributes[:nil] = true
+                      end
+
+                      options[:builder].tag!(children,
+                        Hash::Conversions::XML_FORMATTING[type_name] ? Hash::Conversions::XML_FORMATTING[type_name].call(value).to_s : value.to_s,
+                        attributes
+                      )
+                  end
+                end
+              end
+            end
+          end
+        end
+
+      end
+    end
+  end
+end

Added: ode/sandbox/singleshot/lib/extensions/validators.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/lib/extensions/validators.rb?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/lib/extensions/validators.rb (added)
+++ ode/sandbox/singleshot/lib/extensions/validators.rb Wed Nov 21 23:54:08 2007
@@ -0,0 +1,53 @@
+module ActiveRecord
+  class Base
+    class << self
+
+      Errors.default_error_messages[:invalid_url] = 'Not a valid URL'
+      Errors.default_error_messages[:invalid_email] = 'Not a valid e-mail address'
+     
+      # Validates that each attribute is a URL and also normalizes the URL before saving it.
+      #
+      # The URL is checked to be valid, include a schema and host name (and therefore be absolute),
+      # and only uses an allowed scheme.  The allowed schemes are specified by the :schemes option,
+      # defaulting to HTTP and HTTPS.  The normalized URL has its scheme in all lower case, and so
+      # should the names passed to :scheme.
+      #
+      # For example:
+      #   # Only allow HTPS
+      #   validates_url :secure_url, :schemes=>['https']
+      def validates_url(*attr_names)
+        configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid_url], :on => :save,
+                          :schemes=>['http', 'https'] }
+        configuration.update(attr_names.extract_options!)
+
+        before_validation do |record|
+          attr_names.each do |attr_name|
+            url = record.send(attr_name) 
+            if url && uri = URI(url) rescue nil
+              uri.normalize!
+              uri.scheme = uri.scheme.downcase if uri.scheme
+              record.send "#{attr_name}=", uri.to_s
+            end
+          end
+        end
+
+        validates_each(attr_names, configuration) do |record, attr_name, value|
+          uri = URI.parse(value) rescue nil
+          record.errors.add attr_name, configuration[:message] unless uri && uri.scheme && uri.host &&
+            (configuration[:schemes].nil? || configuration[:schemes].include?(uri.scheme.downcase))
+        end
+      end
+
+      # Validates that each attribute looks like a valid e-mail address.  Does not check that the
+      # e-mail address makes sense, only that it is more likely to be an e-mail address than a phone number.
+      def validates_email(*attr_names)
+        configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid_email], :on => :save }
+        configuration.update(attr_names.extract_options!)
+        configuration.update(:with => /^([^@\s]+)@[-a-z0-9]+(\.[-a-z0-9]+)*$/)
+        attr_names << configuration
+        validates_format_of *attr_names
+      end
+
+    end
+  end
+end

Added: ode/sandbox/singleshot/lib/tasks/database.rake
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/lib/tasks/database.rake?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/lib/tasks/database.rake (added)
+++ ode/sandbox/singleshot/lib/tasks/database.rake Wed Nov 21 23:54:08 2007
@@ -0,0 +1,7 @@
+namespace :db do
+
+  desc 'Rebuild the database by running all migrations again'
+  task 'rebuild'=>['environment', 'drop', 'create', 'migrate', 'test:clone']
+
+end
+

Modified: ode/sandbox/singleshot/public/javascripts/application.js
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/public/javascripts/application.js?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/public/javascripts/application.js (original)
+++ ode/sandbox/singleshot/public/javascripts/application.js Wed Nov 21 23:54:08 2007
@@ -1,2 +1,4 @@
 // Place your application-specific JavaScript functions and classes here
 // This file is automatically included by javascript_include_tag :defaults
+
+var Singleshot = new Object();

Modified: ode/sandbox/singleshot/public/stylesheets/default.css
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/public/stylesheets/default.css?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/public/stylesheets/default.css (original)
+++ ode/sandbox/singleshot/public/stylesheets/default.css Wed Nov 21 23:54:08 2007
@@ -109,3 +109,23 @@
 div.login .error {
   color: red;
 }
+
+
+/** Tabular forms **/
+
+form table {
+  margin: 0 0 1em 0;
+}
+form table td {
+  text-align: left;
+  vertical-align: top;
+  padding: 0.5em;
+}
+form table td label {
+  font-weight: bold;
+}
+form table td span.tip {
+  display: block;
+  color: #444;
+  font-size: 0.9em;
+}

Added: ode/sandbox/singleshot/spec/common.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/common.rb?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/spec/common.rb (added)
+++ ode/sandbox/singleshot/spec/common.rb Wed Nov 21 23:54:08 2007
@@ -0,0 +1,41 @@
+module Specs
+
+  module Authentication
+
+    def self.included(mod)
+      mod.after :all do
+        Person.delete_all
+      end
+    end
+
+    def person(nickname)
+      @_people ||= {}
+      @_people[nickname.to_s] ||= Person.find_by_nickname(nickname) ||
+        Person.create(:email=>"#{nickname}@apache.org", :password=>'secret')
+    end
+
+    def authenticate(person)
+      session[:person_id] = person.id
+    end
+
+  end
+
+  module Tasks
+
+    def self.included(mod)
+      mod.after :all do
+        Task.delete_all
+      end
+    end
+
+    def default_values(merge = nil)
+      base = {
+        :title=>'Test Task model',
+        :perform_url=>'http://test.host/do',
+        :completion_url=>'http://test.host/complete' }
+      merge ? base.merge(merge) : base
+    end
+
+  end
+
+end

Modified: ode/sandbox/singleshot/spec/controllers/sessions_controller_spec.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/controllers/sessions_controller_spec.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/controllers/sessions_controller_spec.rb (original)
+++ ode/sandbox/singleshot/spec/controllers/sessions_controller_spec.rb Wed Nov 21 23:54:08 2007
@@ -4,7 +4,7 @@
 describe SessionsController do
 
   it 'should route show/create/destroy actions to same URL' do
-    route_for(:controller =>'sessions', :action=>'show').should == '/session'
+    route_for(:controller =>'sessions', :action=>'show').should eql('/session')
   end
 
 end
@@ -23,20 +23,22 @@
 
 describe SessionsController, 'POST' do
 
+  include Specs::Authentication
+
   before :all do
-    @credentials = { :login=>'john', :password=>'secret' }
-    @john = Person.create(:email=>'john@apache.org', :password=>@credentials[:password])
+    @credentials = { :login=>'assaf', :password=>'secret' }
+    @person = person(@credentials[:login])
   end
 
-  it 'should redirect to login page with error, if no login parameters' do
+  it 'should redirect to login page with no error if login is empty' do
     post :create
     response.should redirect_to(session_url)
-    flash[:error].should match(/no account/i)
+    flash[:error].should be_blank
     session[:person_id].should be_nil
   end
 
   it 'should redirect to login page with error, if login/password do not match' do
-    post :create, @credentials.update(:password=>'wrong')
+    post :create, @credentials.merge(:password=>'wrong')
     response.should redirect_to(session_url)
     flash[:error].should match(/no account/i)
     session[:person_id].should be_nil
@@ -45,7 +47,7 @@
   it 'should establish new session if authenticated' do
     post :create, @credentials
     flash[:error].should be_nil
-    session[:person_id].should == @john.id
+    session[:person_id].should eql(@person.id)
   end
 
   it 'should redirect to root_url if authenticated' do
@@ -53,8 +55,8 @@
     response.should redirect_to(root_url)
   end
 
-  it 'should redirect to flash[:follow] if specified' do
-    post :create, @credentials, nil, { :follow=>'http://foo' }
+  it 'should redirect to flash[:return_to] if specified' do
+    post :create, @credentials, nil, { :return_to=>'http://foo' }
     response.should redirect_to('http://foo')
   end
 

Added: ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb?rev=597323&view=auto
==============================================================================
--- ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb (added)
+++ ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb Wed Nov 21 23:54:08 2007
@@ -0,0 +1,82 @@
+require File.dirname(__FILE__) + '/../spec_helper'
+
+describe TasksController do
+
+end
+
+
+describe TasksController, 'authentication' do
+
+  include Specs::Authentication
+
+  before :all do
+    @person = person('assaf')
+  end
+  
+  it 'should map to /tasks' do
+    route_for(:controller=>'tasks', :action=>'create').should eql('/tasks')
+  end
+
+  def http_authenticate(login, password)
+    request.headers['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(login, password)
+  end
+
+  it 'should use session authentication for HTML requests' do
+    post :create
+    response.should redirect_to(session_url)
+  end
+
+  it 'should accept session authentication' do
+    authenticate @person
+    Task.should_receive(:create)
+    post :create
+  end
+
+  it 'should use HTTP Basic authentication for XML requests' do
+    post :create, :format=>'xml'
+    response.should be_unauthorized
+  end
+
+  it 'should use HTTP Basic authentication for JSON requests' do
+    post :create, :format=>'json'
+    response.should be_unauthorized
+  end
+
+  it 'should use HTTP Basic authentication if authentication header provider' do
+    http_authenticate 'assaf', 'wrong'
+    post :create
+    response.should be_unauthorized
+  end
+
+  it 'should accept HTTP Basic authentication' do
+    http_authenticate 'assaf', 'secret'
+    Task.should_receive(:create)
+    post :create
+  end
+
+  it 'should redirect to login page with return to self' do
+    post :create
+    response.should redirect_to(session_url)
+    flash[:return_to].should eql('http://test.host/tasks')
+  end
+
+end
+
+
+describe TasksController, 'create' do
+
+  include Specs::Authentication, Specs::Tasks
+
+  before :each do
+    authenticate person('assaf')
+  end
+
+  it 'should infer XML as completion_format from accepted content type' do
+    post :create, :format=>'xml'
+  end
+
+  it 'should infer JSON as completion_format from accepted content type'
+  it 'should default to XML as completion_format for all other content types'
+  it 'should pass completion_format from request'
+
+end

Modified: ode/sandbox/singleshot/spec/enhancements.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/enhancements.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/enhancements.rb (original)
+++ ode/sandbox/singleshot/spec/enhancements.rb Wed Nov 21 23:54:08 2007
@@ -2,7 +2,9 @@
   # TestResponse for functional, CgiResponse for integration.
   class AbstractResponse
     StatusCodes::SYMBOL_TO_STATUS_CODE.each do |symbol, code|
-      define_method("#{symbol}?") { self.code == code.to_s } unless instance_methods.include?("#{symbol}?")
+      unless instance_methods.include?("#{symbol}?")
+        define_method("#{symbol}?") { self.code == code.to_s }
+      end
     end
   end
 end

Modified: ode/sandbox/singleshot/spec/models/task_spec.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/models/task_spec.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/models/task_spec.rb (original)
+++ ode/sandbox/singleshot/spec/models/task_spec.rb Wed Nov 21 23:54:08 2007
@@ -1,20 +1,8 @@
 require File.dirname(__FILE__) + '/../spec_helper'
 
-module TaskSpec
-
-  def default_values
-    { :title=>'Test Task model' }
-  end
-
-  def person(role)
-    Person.find_by_nickname(role.to_s) || Person.create(:email=>"#{role}@apache.org")
-  end
-
-end
-
 
 describe Task, 'to_param' do
-  include TaskSpec
+  include Specs::Tasks
 
   before :each do
     @task = Task.create(default_values)
@@ -25,47 +13,52 @@
   end
 
   it 'should resolve to task ID' do
-    @task.to_param.to_i.should == @task.id 
+    @task.to_param.to_i.should eql(@task.id)
   end
 
   it 'should contain title' do
-    title.should == @task.title.gsub(' ', '-')
+    title.should eql(@task.title.gsub(' ', '-'))
   end
 
   it 'should change all non-word characters to dashes' do
     @task.title = 'foo*bar+baz faz_'
-    title.should == 'foo-bar-baz-faz_'
+    title.should eql('foo-bar-baz-faz_')
   end
 
   it 'should consolidate multiple dashes' do
     @task.title = 'foo**bar--baz -faz'
-    title.should == 'foo-bar-baz-faz'
+    title.should eql('foo-bar-baz-faz')
   end
 
   it 'should remove dashes at end' do
     @task.title = 'foo**'
-    title.should == 'foo'
+    title.should eql('foo')
+  end
+
+  it 'should leave UTF-8 text along' do
+    @task.title = 'josé'
+    CGI.escape(title).should eql('jos%C3%A9')
   end
 
 end
 
 
 describe Task, 'status' do
-  include TaskSpec
+  include Specs::Tasks
 
   before :each do
     @task = Task.new(default_values)
   end
 
   it 'should default to :active' do
-    @task.status.should == :active
+    @task.status.should eql(:active)
   end
 
   it 'should allow valid status code' do
     Task::STATUSES.each do |status|
       @task.status = status
       @task.save
-      Task.find(@task.id).status.should == status
+      Task.find(@task.id).status.should eql(status)
     end
   end
 
@@ -80,7 +73,7 @@
   end
 
   it 'should protect status code from mass assignment' do
-    lambda { @task.update_attributes :status=>:completed }.should_not change { @task.status }
+    lambda { @task.update_attributes! :status=>:completed }.should_not change { @task.status }
   end
 
   it 'should resolve symbol to index value' do
@@ -92,25 +85,209 @@
 end
 
 
-describe Task, 'completion format' do
-  include TaskSpec
+describe Task, 'priority' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.new(default_values.except(:priority))
+  end
+
+  it 'should default to 1' do
+    @task.priority.should be(1)
+  end
+
+  it 'should allow values from 1 to 5' do
+    priorities = Array.new(7) { |i| i }
+    priorities.map { |p| @task.update_attributes :priority=>p }.should eql([false] + [true] * 5 + [false])
+  end
+
+  it 'should accept string value' do
+    lambda { @task.update_attributes! :priority=>'4' }.should change(@task, :priority).to(4)
+  end
+
+  it 'should accept nil and reset to default' do
+    @task.priority = 4
+    lambda { @task.update_attributes! :priority=>nil }.should change(@task, :priority).to(1)
+  end
+
+end
+
+
+describe Task, 'due_on' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.new(default_values.except(:due_on))
+  end
+
+  it 'should default to nil' do
+    @task.due_on.should be_nil
+  end
+
+  it 'should accept time and return date' do
+    now = Time.now
+    lambda { @task.update_attributes! :due_on=>now ; @task.reload }.should change(@task, :due_on).to(now.to_date)
+  end
+
+  it 'should accept date and return it' do
+    today = Date.today
+    lambda { @task.update_attributes! :due_on=>today ; @task.reload }.should change(@task, :due_on).to(today)
+  end
+
+  it 'should accept ISO 8601 date string and return date' do
+    today = Date.today
+    lambda { @task.update_attributes! :due_on=>today.to_s ; @task.reload }.should change(@task, :due_on).to(today)
+  end
+
+  it 'should accept ISO 8601 time string and return date' do
+    now = Time.now
+    lambda { @task.update_attributes! :due_on=>now.iso8601 ; @task.reload }.should change(@task, :due_on).to(now.to_date)
+  end
+
+end
+
+
+describe Task, 'perform_url' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.new(default_values.except(:perform_url))
+  end
+
+  it 'should be required to save task' do
+    @task.valid?.should be_false
+    @task.errors.on(:perform_url).to_s.should match(/URL/)
+  end
+
+  it 'should be tested for validity' do
+    @task.perform_url = 'http://+++'
+    @task.valid?.should be_false
+    @task.errors.on(:perform_url).to_s.should match(/URL/)
+  end
+
+  it 'should allow HTTP URLS' do
+    @task.perform_url = 'http://test.host/do'
+    @task.valid?.should be_true
+  end
+
+  it 'should allow HTTPS URLS' do
+    @task.perform_url = 'https://test.host/do'
+    @task.valid?.should be_true
+  end
+
+  it 'should not allow other URL schemes' do
+    @task.perform_url = 'ftp://test.host/do'
+    @task.valid?.should be_false
+    @task.errors.on(:perform_url).to_s.should match(/URL/)
+  end
+
+end
+
+
+describe Task, 'completion_url' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.new(default_values.except(:completion_url))
+  end
+
+  it 'should be required to save task' do
+    @task.valid?.should be_false
+    @task.errors.on(:completion_url).to_s.should match(/URL/)
+  end
+
+  it 'should be tested for validity' do
+    @task.completion_url = 'http://+++'
+    @task.valid?.should be_false
+    @task.errors.on(:completion_url).to_s.should match(/URL/)
+  end
+
+  it 'should allow HTTP URLS' do
+    @task.completion_url = 'http://test.host/completed'
+    @task.valid?.should be_true
+  end
+
+  it 'should allow HTTPS URLS' do
+    @task.completion_url = 'https://test.host/completed'
+    @task.valid?.should be_true
+  end
+
+  it 'should not allow other URL schemes' do
+    @task.completion_url = 'ftp://test.host/completed'
+    @task.valid?.should be_false
+    @task.errors.on(:completion_url).to_s.should match(/URL/)
+  end
+
+end
+
+
+describe Task, 'view_url' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.new(default_values.except(:view_url))
+  end
+
+  it 'should be optional' do
+    @task.valid?.should be_true
+  end
+
+  it 'should be tested for validity' do
+    @task.view_url = 'http://+++'
+    @task.valid?.should be_false
+    @task.errors.on(:view_url).to_s.should match(/URL/)
+  end
+
+  it 'should allow HTTP URLS' do
+    @task.view_url = 'http://test.host/see'
+    @task.valid?.should be_true
+  end
+
+  it 'should allow HTTPS URLS' do
+    @task.view_url = 'https://test.host/see'
+    @task.valid?.should be_true
+  end
+
+  it 'should not allow other URL schemes' do
+    @task.view_url = 'ftp://test.host/see'
+    @task.valid?.should be_false
+    @task.errors.on(:view_url).to_s.should match(/URL/)
+  end
+
+end
+
+
+describe Task, 'completion_format' do
+  include Specs::Tasks
 
   before :each do
     @task = Task.new(default_values.except(:completion_format))
   end
 
   it 'should default to Mime::XML' do
-    @task.completion_format.should == Mime::XML
+    @task.completion_format.should be(Mime::XML)
   end
 
   it 'should accept Mime::XML' do
-    @task.update_attributes :completion_format=>Mime::XML
-    Task.find(@task.id).completion_format.should == Mime::XML
+    @task.update_attributes! :completion_format=>Mime::XML
+    Task.find(@task.id).completion_format.should be(Mime::XML)
   end
 
   it 'should accept Mime::JSON' do
-    @task.update_attributes :completion_format=>Mime::JSON
-    Task.find(@task.id).completion_format.should == Mime::JSON
+    @task.update_attributes! :completion_format=>Mime::JSON
+    Task.find(@task.id).completion_format.should be(Mime::JSON)
+  end
+
+  it 'should accept content type' do
+    Task::COMPLETION_FORMATS.each do |format|
+      lambda { @task.update_attributes! :completion_format=>format.to_s }.should change(@task, :completion_format).to(format)
+    end
+  end
+
+  it 'should accept extension name' do
+    Task::COMPLETION_FORMATS.each do |format|
+      lambda { @task.update_attributes! :completion_format=>format.to_sym.to_s }.should change(@task, :completion_format).to(format)
+    end
   end
 
   it 'should reject unsupported formats' do
@@ -122,7 +299,7 @@
 
 
 describe Task, 'data' do
-  include TaskSpec
+  include Specs::Tasks
 
   before :each do
     @task = Task.new(default_values)
@@ -137,7 +314,7 @@
   end
 
   it 'should be hash with indifferent access' do
-    @task.update_attributes :data=>{ :foo=>1 }
+    @task.update_attributes! :data=>{ :foo=>1 }
     Task.find(@task.id).data[:foo].should be(1)
     Task.find(@task.id).data['foo'].should be(1)
   end
@@ -149,7 +326,7 @@
   end
 
   it 'should store changes' do
-    lambda { @task.update_attributes :data=>{:foo=>1} }.should change { @task.id && Task.find(@task.id).data }.to('foo'=>1)
+    lambda { @task.update_attributes! :data=>{:foo=>1} }.should change { @task.id && Task.find(@task.id).data }.to('foo'=>1)
     lambda do
       @task.data[:bar] = 2
       @task.save
@@ -160,10 +337,9 @@
 
 
 describe Task, 'token' do
+  include Specs::Tasks, Specs::Authentication
 
-  include TaskSpec
-
-  before :all do
+  before :each do
     @creator = person('creator')
     @owner = person('owner')
     @task = Task.new(:creator=>@creator, :owner=>@owner)
@@ -171,18 +347,18 @@
     @owner_token = @task.token_for(@owner)
   end
 
-  it 'should be MD5 digest' do
-    @creator_token.should match(/^[a-z0-9]{32}$/)
-    @owner_token.should match(/^[a-z0-9]{32}$/)
+  it 'should be hex digest' do
+    @creator_token.should match(/^[a-z0-9]{32,}$/)
+    @owner_token.should match(/^[a-z0-9]{32,}$/)
   end
 
   it 'should be consistent for a given person' do
-    @creator_token.should == @task.token_for(@creator)
-    @owner_token.should == @task.token_for(@owner)
+    @creator_token.should eql(@task.token_for(@creator))
+    @owner_token.should eql(@task.token_for(@owner))
   end
 
   it 'should be different for different people' do
-    @creator_token.should_not == @owner_token
+    @creator_token.should_not eql(@owner_token)
   end
 
   it 'should resolve back to person' do
@@ -190,12 +366,15 @@
     @task.authorize(@owner_token).should be(@owner)
   end
 
+  it 'should resolve only if person is a stakeholder' do
+    lambda { @task.owner = nil }.should change { @task.authorize(@owner_token) }.from(@owner).to(nil)
+  end
+
 end
 
 
 describe Task, 'singular role' do
-
-  include TaskSpec
+  include Specs::Tasks, Specs::Authentication
 
   before :all do
     @person = person('person')
@@ -213,26 +392,40 @@
 
   it 'should have getter and setter methods' do
     Task::SINGULAR_ROLES.each do |role|
-      lambda { @task.update_attributes role=>@person }.should change(@task, role).to(@person)
+      lambda { @task.update_attributes! role=>@person }.should change(@task, role).to(@person)
     end
   end
 
   it 'should have checker methods' do
     Task::SINGULAR_ROLES.each do |role|
-      lambda { @task.update_attributes role=>@person }.should change { @task.send("#{role}?", @person) }.to(true)
+      lambda { @task.update_attributes! role=>@person }.should change { @task.send("#{role}?", @person) }.to(true)
     end
   end
 
   it 'should be able to remove person' do
     Task::SINGULAR_ROLES.each do |role|
-      @task.update_attributes role=>@person
-      lambda { @task.update_attributes role=>nil }.should change(@task, role).from(@person).to(nil)
+      @task.update_attributes! role=>@person
+      lambda { @task.update_attributes! role=>nil }.should change(@task, role).from(@person).to(nil)
     end
   end
 
   it 'should accept person at creation' do
     Task::SINGULAR_ROLES.each do |role|
-      Task.create(default_values.merge(role=>person(role))).send(role).should eql(person(role))
+      Task.create(default_values.merge(role=>@person)).send(role).should eql(@person)
+    end
+  end
+
+  it 'should treat blank as nil' do
+    Task::SINGULAR_ROLES.each do |role|
+      @task.update_attributes! role=>@person
+      lambda { @task.update_attributes! role=>'' }.should change(@task, role).from(@person).to(nil)
+    end
+  end
+
+  it 'should accept unknown identities but fail to save' do
+    Task::SINGULAR_ROLES.each do |role|
+      lambda { @task.update_attributes! role=>'unknown' }.
+        should raise_error(ActiveRecord::RecordInvalid, /cannot find person/i)
     end
   end
 
@@ -240,10 +433,10 @@
 
 
 describe Task, 'plural role' do
-
-  include TaskSpec
+  include Specs::Tasks, Specs::Authentication
 
   before :each do
+    @people = Array.new(3) { |i| person("person#{i}") }
     @task = Task.new(default_values)
   end
 
@@ -251,10 +444,6 @@
     role.to_s.pluralize
   end
 
-  def people
-    @people ||= Array.new(3) { |i| person("person#{i}") }
-  end
-
   it 'should start as empty array' do
     Task::PLURAL_ROLES.each do |role|
       @task.send(plural(role)).should be_empty
@@ -263,43 +452,58 @@
 
   it 'should have getter and setter methods' do
     Task::PLURAL_ROLES.each do |role|
-      lambda { @task.update_attributes plural(role)=>people }.should change(@task, plural(role)).to(people)
+      lambda { @task.update_attributes! plural(role)=>@people }.should change(@task, plural(role)).to(@people)
     end
   end
 
   it 'should have checker methods' do
     Task::PLURAL_ROLES.each do |role|
-      lambda { @task.update_attributes plural(role)=>people.first }.should change { @task.send("#{role}?", people.first) }.to(true)
-      @task.send("#{role}?", people.last).should be_false
+      lambda { @task.update_attributes! plural(role)=>@people.first }.
+        should change { @task.send("#{role}?", @people.first) }.to(true)
+      @task.send("#{role}?", @people.last).should be_false
     end
   end
 
   it 'should be able to add person' do
     Task::PLURAL_ROLES.each do |role|
-      @task.update_attributes plural(role)=>people[0..-2]
-      lambda { @task.update_attributes plural(role)=>people }.should change(@task, plural(role)).
-        from(people[0..-2]).to(people)
+      @task.update_attributes! plural(role)=>@people[0..-2]
+      lambda { @task.update_attributes! plural(role)=>@people }.should change(@task, plural(role)).
+        from(@people[0..-2]).to(@people)
     end
   end
 
   it 'should be able to remove person' do
     Task::PLURAL_ROLES.each do |role|
-      @task.update_attributes plural(role)=>people
-      lambda { @task.update_attributes plural(role)=>people[0..-2] }.should change(@task, plural(role)).
-        from(people).to(people[0..-2])
+      @task.update_attributes! plural(role)=>@people
+      lambda { @task.update_attributes! plural(role)=>@people[0..-2] }.should change(@task, plural(role)).
+        from(@people).to(@people[0..-2])
     end
   end
 
   it 'should add each person only once' do
     Task::PLURAL_ROLES.each do |role|
-      @task.update_attributes plural(role)=>[people.first, people.first]
-      @task.send(plural(role)).should eql([people.first])
+      @task.update_attributes! plural(role)=>[@people.first, @people.first]
+      @task.send(plural(role)).should eql([@people.first])
     end
   end
 
   it 'should accept person at creation' do
     Task::PLURAL_ROLES.each do |role|
-      Task.create(default_values.merge(plural(role)=>people)).send(plural(role)).should eql(people)
+      Task.create(default_values.merge(plural(role)=>@people)).send(plural(role)).should eql(@people)
+    end
+  end
+
+  it 'should treat blank as empty' do
+    Task::PLURAL_ROLES.each do |role|
+      @task.update_attributes! plural(role)=>@people
+      lambda { @task.update_attributes! plural(role)=>'' }.should change(@task, plural(role)).from(@people).to([])
+    end
+  end
+
+  it 'should accept unknown identities but fail to save' do
+    Task::PLURAL_ROLES.each do |role|
+      lambda { @task.update_attributes! plural(role)=>(@people << 'unknown') }.
+        should raise_error(ActiveRecord::RecordInvalid, /cannot find person/i)
     end
   end
 
@@ -307,8 +511,7 @@
 
 
 describe Task, 'creator' do
-
-  include TaskSpec
+  include Specs::Tasks
 
   it 'should be singular role' do
     Task::SINGULAR_ROLES.should include(:creator)
@@ -322,6 +525,7 @@
 
 
 describe Task, 'owner' do
+  include Specs::Tasks
 
   it 'should be singular role' do
     Task::SINGULAR_ROLES.should include(:owner)
@@ -334,55 +538,25 @@
 end
 
 
-describe Task, 'in_role' do
-
-  include TaskSpec
-
-  before :each do
-    @task = Task.new(default_values.merge(:owner=>person(:owner), :creator=>person(:creator)))
-  end
-
-  it 'should return all people in that role' do
-    @task.in_role(:owner).should eql([person('owner')])
-  end
-
-  it 'should return all people in multiple roles' do
-    @task.in_role(:owner, :creator).sort_by(&:id).should eql([person('owner'), person('creator')].sort_by(&:id))
-  end
-
-  it 'should return all people for :any' do
-    @task.in_role(:any).sort_by(&:id).should eql([person('owner'), person('creator')].sort_by(&:id))
-  end
-
-end
-
-
-describe Task, 'in_role?' do
-
-  include TaskSpec
+describe Task, 'stakeholder?' do
+  include Specs::Tasks, Specs::Authentication
 
-  before :each do
+  before :all do
     @task = Task.new(default_values.merge(:owner=>person(:owner), :creator=>person(:creator)))
   end
-
-  it 'should return true if person in that role' do
-    @task.in_role?(person(:owner), :owner).should be_true
-    @task.in_role?(person(:creator), :creator).should be_true
-  end
-
-  it 'should return true if person is in any of the roles' do
-    @task.in_role?(person(:owner), :creator, :owner).should be_true
-    @task.in_role?(person(:creator), :creator, :owner).should be_true
+  
+  it 'should return true if person associated with task' do
+    @task.stakeholder?(person(:owner)).should be_true
+    @task.stakeholder?(person(:creator)).should be_true
   end
 
-  it 'should return false if person not in role' do
-    @task.in_role?(person(:owner), :creator).should be_false
-    @task.in_role?(person(:creator), :owner).should be_false
+  it 'should return false if person not associated with task' do
+    @task.stakeholder?(person(:unknown)).should be_false
   end
 
-  it 'should return true if person in any role for :any' do
-    @task.in_role?(person(:owner), :any).should be_true
-    @task.in_role?(person(:unknown), :any).should be_false
+  it 'should return true for task admin' do
+    Task.admins << person(:admin)
+    @task.stakeholder?(person(:admin)).should be_true
   end
 
 end

Modified: ode/sandbox/singleshot/spec/spec_helper.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/spec_helper.rb?rev=597323&r1=597322&r2=597323&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/spec_helper.rb (original)
+++ ode/sandbox/singleshot/spec/spec_helper.rb Wed Nov 21 23:54:08 2007
@@ -4,6 +4,7 @@
 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
 require 'spec/rails'
 require File.expand_path(File.dirname(__FILE__) + "/enhancements")
+require File.expand_path(File.dirname(__FILE__) + "/common")
 
 Spec::Runner.configure do |config|
   config.use_transactional_fixtures = true