Auditors::Authentication
fixes CNVS-390 stores and allows querying by user/account/pseudonym of login/logout events. test-plan: [setup] - set up an 'auditors' keyspace in cassandra and run migrations - have shardX and shardY on one database server, and shardZ on a different database server - have accountW and accountX on shardX - have accountY and accountZ on shardY and shardZ, respectively - have userA on shardX with pseudonymAW in accountW and pseudonymAX in accountX (cross-account, single-shard user) - have userB on shardY with pseudonymBY in accountY and pseudonymBX in accountX (cross-shard user) - have userC on shardZ with pseudonymCZ in accountZ and pseudonymCX in accountX (cross-db-server user) - log in and out of each pseudonym above multiple times [index isolation] - /api/v1/audit/authentication/pseudonyms/<pseudonymAX> should include logins and logouts from pseudonymAX only - /api/v1/audit/authentication/accounts/<accountX> should include logins and logouts from pseudonymAX, pseudonymBX, and pseudonymCX but not pseudonymAW - /api/v1/audit/authentication/users/<userA> should include logins and logouts from both pseudonymAW and pseudonymAX but not pseudonymBX or pseudonymCX [permission isolation] (in each of these, either :view_statistics or :manage_user_logins on an account qualifies as "having permission") - /api/v1/audit/authentication/pseudonyms/<pseudonymAX> should be unauthorized if the current user doesn't have permission on accountX - /api/v1/audit/authentication/accounts/<accountX> should be unauthorized if the current user doesn't have permission on accountX - /api/v1/audit/authentication/users/<userA> should be unauthorized if the current user doesn't have permission on either of accountW or accountX - /api/v1/audit/authentication/users/<userA> should include logins and logouts from accountW but not from accountX if the current user has permission on accountW but not on accountX [sharding] - /api/v1/audit/authentication/users/<userB> should include logins and logouts from both pseudonymBY and pseudonymBX - /api/v1/audit/authentication/users/<userB> should not include duplicate logins and logouts from either pseudonymBY and pseudonymBX (potential for bug due to both pseudonyms' shards being on the same database server) - /api/v1/audit/authentication/users/<userC> should include logins and logouts from both pseudonymCZ and pseudonymCX Change-Id: I74b1573b346935f733fe5b07919d2d450cf07592 Reviewed-on: https://gerrit.instructure.com/21829 Reviewed-by: Brian Palmer <brianp@instructure.com> Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Jeremy Putnam <jeremyp@instructure.com> Product-Review: Jacob Fugal <jacob@instructure.com>
This commit is contained in:
parent
a91e6c32b5
commit
59737086e7
|
@ -0,0 +1,123 @@
|
|||
#
|
||||
# Copyright (C) 2013 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# @API Authentications Log
|
||||
#
|
||||
# Query audit log of authentication events (logins and logouts).
|
||||
#
|
||||
# For each endpoint, a compound document is returned. The primary collection of
|
||||
# event objects is paginated, ordered by date descending. Secondary collections
|
||||
# of pseudonyms (logins), accounts, and users related to the returned events
|
||||
# are also included. Refer to the Logins, Accounts, and Users APIs for
|
||||
# descriptions of the objects in those collections.
|
||||
#
|
||||
# @object AuthenticationEvent
|
||||
# {
|
||||
# // timestamp of the event
|
||||
# "created_at": "2012-07-19T15:00:00-06:00",
|
||||
#
|
||||
# // authentication event type ('login' or 'logout')
|
||||
# "event_type": "login",
|
||||
#
|
||||
# // ID of the pseudonym (login) associated with the event
|
||||
# "pseudonym_id": 9478,
|
||||
#
|
||||
# // ID of the account associated with the event. will match the
|
||||
# // account_id in the associated pseudonym.
|
||||
# "account_id": 2319,
|
||||
#
|
||||
# // ID of the user associated with the event will match the user_id in
|
||||
# // the associated pseudonym.
|
||||
# "user_id": 362
|
||||
# }
|
||||
#
|
||||
class AuthenticationAuditApiController < ApplicationController
|
||||
include Api::V1::AuthenticationEvent
|
||||
|
||||
# @API Query by pseudonym.
|
||||
#
|
||||
# List authentication events for a given pseudonym.
|
||||
#
|
||||
def for_pseudonym
|
||||
@pseudonym = Pseudonym.active.find(params[:pseudonym_id])
|
||||
if account_visible(@pseudonym.account) || account_visible(Account.site_admin)
|
||||
events = Auditors::Authentication.for_pseudonym(@pseudonym)
|
||||
render_events(events, @pseudonym)
|
||||
else
|
||||
render_unauthorized_action(@pseudonym)
|
||||
end
|
||||
end
|
||||
|
||||
# @API Query by account.
|
||||
#
|
||||
# List authentication events for a given account.
|
||||
#
|
||||
def for_account
|
||||
@account = api_find(Account.active, params[:account_id])
|
||||
if account_visible(@account) || account_visible(Account.site_admin)
|
||||
events = Auditors::Authentication.for_account(@account)
|
||||
render_events(events, @account)
|
||||
else
|
||||
render_unauthorized_action(@account)
|
||||
end
|
||||
end
|
||||
|
||||
# @API Query by user.
|
||||
#
|
||||
# List authentication events for a given user.
|
||||
#
|
||||
def for_user
|
||||
@user = api_find(User.active, params[:user_id])
|
||||
if @user == @current_user || account_visible(Account.site_admin)
|
||||
events = Auditors::Authentication.for_user(@user)
|
||||
render_events(events, @user)
|
||||
else
|
||||
accounts = Shard.with_each_shard(@user.associated_shards) do
|
||||
Account.joins(:pseudonyms).where(:pseudonyms => {
|
||||
:user_id => @user,
|
||||
:workflow_state => 'active'
|
||||
}).all
|
||||
end
|
||||
visible_accounts = accounts.select{ |a| account_visible(a) }
|
||||
if visible_accounts == accounts
|
||||
events = Auditors::Authentication.for_user(@user)
|
||||
render_events(events, @user)
|
||||
elsif visible_accounts.present?
|
||||
pseudonyms = Shard.partition_by_shard(visible_accounts) do |shard_accounts|
|
||||
@user.active_pseudonyms.where(:account_id => shard_accounts).all
|
||||
end
|
||||
events = Auditors::Authentication.for_pseudonyms(pseudonyms)
|
||||
render_events(events, @user)
|
||||
else
|
||||
render_unauthorized_action(@user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_visible(account)
|
||||
account.grants_rights?(@current_user, nil, :view_statistics, :manage_user_logins).values.any?
|
||||
end
|
||||
|
||||
def render_events(events, context)
|
||||
route = polymorphic_url([:api_v1, :audit_authentication, context])
|
||||
events = Api.paginate(events, self, route)
|
||||
render :json => authentication_events_compound_json(events, @current_user, session)
|
||||
end
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
#
|
||||
# Copyright (C) 2011 Instructure, Inc.
|
||||
# Copyright (C) 2011-2013 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
|
@ -525,6 +525,7 @@ class PseudonymSessionsController < ApplicationController
|
|||
def successful_login(user, pseudonym, otp_passed = false)
|
||||
@current_user = user
|
||||
@current_pseudonym = pseudonym
|
||||
Auditors::Authentication.record(@current_pseudonym, 'login')
|
||||
|
||||
otp_passed ||= cookies['canvas_otp_remember_me'] &&
|
||||
@current_user.validate_otp_secret_key_remember_me_cookie(cookies['canvas_otp_remember_me'])
|
||||
|
@ -577,6 +578,11 @@ class PseudonymSessionsController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def logout_current_user
|
||||
Auditors::Authentication.record(@current_pseudonym, 'logout')
|
||||
super
|
||||
end
|
||||
|
||||
def oauth2_auth
|
||||
if params[:code] || params[:error]
|
||||
# hopefully the user never sees this, since it's an oob response and the
|
||||
|
|
|
@ -860,6 +860,12 @@ FakeRails3Routes.draw do
|
|||
delete 'courses/:course_id/enrollments/:id', :action => :destroy
|
||||
end
|
||||
|
||||
scope(:controller => :authentication_audit_api) do
|
||||
get 'audit/authentication/pseudonyms/:pseudonym_id', :action => :for_pseudonym, :path_name => 'audit_authentication_pseudonym'
|
||||
get 'audit/authentication/accounts/:account_id', :action => :for_account, :path_name => 'audit_authentication_account'
|
||||
get 'audit/authentication/users/:user_id', :action => :for_user, :path_name => 'audit_authentication_user'
|
||||
end
|
||||
|
||||
scope(:controller => :assignments_api) do
|
||||
get 'courses/:course_id/assignments', :action => :index, :path_name => 'course_assignments'
|
||||
get 'courses/:course_id/assignments/:id', :action => :show, :path_name => 'course_assignment'
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
class AddAuthenticationAuditorTables < ActiveRecord::Migration
|
||||
tag :predeploy
|
||||
|
||||
include Canvas::Cassandra::Migration
|
||||
|
||||
def self.cassandra_cluster
|
||||
'auditors'
|
||||
end
|
||||
|
||||
def self.indexes
|
||||
%w(
|
||||
authentications_by_pseudonym
|
||||
authentications_by_account
|
||||
authentications_by_user
|
||||
)
|
||||
end
|
||||
|
||||
def self.up
|
||||
cassandra.execute %{
|
||||
CREATE TABLE authentications (
|
||||
id text PRIMARY KEY,
|
||||
created_at timestamp,
|
||||
pseudonym_id bigint,
|
||||
account_id bigint,
|
||||
user_id bigint,
|
||||
event_type text
|
||||
) WITH
|
||||
compression_parameters:sstable_compression='DeflateCompressor';
|
||||
}
|
||||
|
||||
indexes.each do |index_name|
|
||||
cassandra.execute %{
|
||||
CREATE TABLE #{index_name} (
|
||||
key text,
|
||||
ordered_id text,
|
||||
id text,
|
||||
PRIMARY KEY (key, ordered_id)
|
||||
) WITH
|
||||
compression_parameters:sstable_compression='DeflateCompressor';
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
indexes.each do |index_name|
|
||||
cassandra.execute %{DROP TABLE #{index_name};}
|
||||
end
|
||||
|
||||
cassandra.execute %{DROP TABLE authentications;}
|
||||
end
|
||||
end
|
|
@ -49,5 +49,9 @@ module Api::V1::Account
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def accounts_json(accounts, user, session, includes)
|
||||
accounts.map{ |account| account_json(account, user, session, includes) }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
#
|
||||
# Copyright (C) 2013 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module Api::V1::AuthenticationEvent
|
||||
include Api::V1::Pseudonym
|
||||
include Api::V1::Account
|
||||
include Api::V1::User
|
||||
|
||||
def authentication_event_json(event, user, session)
|
||||
{
|
||||
:created_at => event.created_at.in_time_zone,
|
||||
:event_type => event.event_type,
|
||||
:pseudonym_id => Shard.relative_id_for(event.pseudonym_id),
|
||||
:account_id => Shard.relative_id_for(event.account_id),
|
||||
:user_id => Shard.relative_id_for(event.user_id)
|
||||
}
|
||||
end
|
||||
|
||||
def authentication_events_json(events, user, session)
|
||||
events.map{ |event| authentication_event_json(event, user, session) }
|
||||
end
|
||||
|
||||
def authentication_events_compound_json(events, user, session)
|
||||
pseudonyms = []
|
||||
accounts = []
|
||||
pseudonym_ids = events.map{ |event| event.pseudonym_id }.uniq
|
||||
Shard.partition_by_shard(pseudonym_ids) do |shard_pseudonym_ids|
|
||||
shard_pseudonyms = Pseudonym.where(:id => shard_pseudonym_ids).all
|
||||
account_ids = shard_pseudonyms.map{ |pseudonym| pseudonym.account_id }.uniq
|
||||
accounts.concat Account.where(:id => account_ids).all
|
||||
pseudonyms.concat shard_pseudonyms
|
||||
end
|
||||
|
||||
user_ids = events.map{ |event| event.user_id }.uniq
|
||||
users = Shard.partition_by_shard(user_ids) do |shard_user_ids|
|
||||
User.where(:id => shard_user_ids).all
|
||||
end
|
||||
|
||||
{
|
||||
meta: {primaryCollection: 'events'},
|
||||
events: authentication_events_json(events, user, session),
|
||||
pseudonyms: pseudonyms_json(pseudonyms, user, session),
|
||||
accounts: accounts_json(accounts, user, session, []),
|
||||
users: users_json(users, user, session, [], @domain_root_account)
|
||||
}
|
||||
end
|
||||
end
|
|
@ -8,5 +8,9 @@ module Api::V1::Pseudonym
|
|||
opts = opts.reject { |opt| opt == :sis_user_id } unless pseudonym.account.grants_rights?(current_user, :read_sis, :manage_sis).values.any?
|
||||
api_json(pseudonym, current_user, session, :only => opts)
|
||||
end
|
||||
|
||||
def pseudonyms_json(pseudonyms, current_user, session)
|
||||
pseudonyms.map{ |p| pseudonym_json(p, current_user, session) }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -75,6 +75,10 @@ module Api::V1::User
|
|||
end
|
||||
end
|
||||
|
||||
def users_json(users, current_user, session, includes = [], context = @context, enrollments = nil)
|
||||
users.map{ |user| user_json(user, current_user, session, includes, context, enrollments) }
|
||||
end
|
||||
|
||||
# this mini-object is used for secondary user responses, when we just want to
|
||||
# provide enough information to display a user.
|
||||
# for instance, discussion entries return this json as a sub-object.
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
#
|
||||
# Copyright (C) 2013 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module Auditors; end
|
||||
|
||||
class Auditors::Authentication
|
||||
class Record < ::EventStream::Record
|
||||
def self.generate(pseudonym, event_type)
|
||||
new('id' => UUIDSingleton.instance.generate,
|
||||
'created_at' => Time.zone.now,
|
||||
'pseudonym' => pseudonym,
|
||||
'event_type' => event_type)
|
||||
end
|
||||
|
||||
def initialize(*args)
|
||||
super(*args)
|
||||
|
||||
if attributes['created_at']
|
||||
attributes['created_at'] = Time.zone.at(attributes['created_at'].to_i)
|
||||
end
|
||||
|
||||
if attributes['pseudonym']
|
||||
self.pseudonym = attributes.delete('pseudonym')
|
||||
end
|
||||
end
|
||||
|
||||
def event_type
|
||||
attributes['event_type']
|
||||
end
|
||||
|
||||
def pseudonym_id
|
||||
attributes['pseudonym_id']
|
||||
end
|
||||
|
||||
def account_id
|
||||
attributes['account_id']
|
||||
end
|
||||
|
||||
def user_id
|
||||
attributes['user_id']
|
||||
end
|
||||
|
||||
def pseudonym
|
||||
@pseudonym ||= Pseudonym.find(pseudonym_id)
|
||||
end
|
||||
|
||||
def pseudonym=(pseudonym)
|
||||
@pseudonym = pseudonym
|
||||
attributes['pseudonym_id'] = @pseudonym.global_id
|
||||
attributes['account_id'] = Shard.global_id_for(@pseudonym.account_id)
|
||||
attributes['user_id'] = Shard.global_id_for(@pseudonym.user_id)
|
||||
end
|
||||
|
||||
def user
|
||||
pseudonym.user
|
||||
end
|
||||
|
||||
def account
|
||||
pseudonym.account
|
||||
end
|
||||
end
|
||||
|
||||
Stream = ::EventStream.new do
|
||||
database_name :auditors
|
||||
table :authentications
|
||||
record_type Auditors::Authentication::Record
|
||||
|
||||
add_index :pseudonym do
|
||||
table :authentications_by_pseudonym
|
||||
entry_proc lambda{ |record| record.pseudonym }
|
||||
key_proc lambda{ |pseudonym| pseudonym.global_id }
|
||||
end
|
||||
|
||||
add_index :user do
|
||||
table :authentications_by_user
|
||||
entry_proc lambda{ |record| record.user }
|
||||
key_proc lambda{ |user| user.global_id }
|
||||
end
|
||||
|
||||
add_index :account do
|
||||
table :authentications_by_account
|
||||
entry_proc lambda{ |record| record.account }
|
||||
key_proc lambda{ |account| account.global_id }
|
||||
end
|
||||
end
|
||||
|
||||
def self.record(pseudonym, event_type)
|
||||
return unless pseudonym
|
||||
pseudonym.shard.activate do
|
||||
record = Auditors::Authentication::Record.generate(pseudonym, event_type)
|
||||
Auditors::Authentication::Stream.insert(record)
|
||||
end
|
||||
end
|
||||
|
||||
def self.for_account(account)
|
||||
account.shard.activate do
|
||||
Auditors::Authentication::Stream.for_account(account)
|
||||
end
|
||||
end
|
||||
|
||||
def self.for_pseudonym(pseudonym)
|
||||
pseudonym.shard.activate do
|
||||
Auditors::Authentication::Stream.for_pseudonym(pseudonym)
|
||||
end
|
||||
end
|
||||
|
||||
def self.for_pseudonyms(pseudonyms)
|
||||
# each for_pseudonym does a shard.activate, so this partition_by_shard is
|
||||
# not necessary for correctness. but it improves performance (prevents
|
||||
# shard-thrashing)
|
||||
collections = Shard.partition_by_shard(pseudonyms) do |shard_pseudonyms|
|
||||
shard_pseudonyms.map do |pseudonym|
|
||||
[pseudonym.global_id, Auditors::Authentication.for_pseudonym(pseudonym)]
|
||||
end
|
||||
end
|
||||
BookmarkedCollection.merge(*collections)
|
||||
end
|
||||
|
||||
def self.for_user(user)
|
||||
collections = []
|
||||
dbs_seen = Set.new
|
||||
Shard.with_each_shard(user.associated_shards) do
|
||||
# EventStream is shard-sensitive, but multiple shards may share
|
||||
# a database. if so, we only need to query from it once
|
||||
db = Auditors::Authentication::Stream.database
|
||||
next if dbs_seen.include?(db)
|
||||
dbs_seen << db
|
||||
|
||||
# query from that database, and label it with the database server's id
|
||||
# for merge
|
||||
collections << [
|
||||
Shard.current.database_server.id,
|
||||
Auditors::Authentication::Stream.for_user(user)
|
||||
]
|
||||
end
|
||||
BookmarkedCollection.merge(*collections)
|
||||
end
|
||||
end
|
|
@ -34,12 +34,21 @@ class EventStream
|
|||
Canvas::Cassandra::Database.from_config(database_name)
|
||||
end
|
||||
|
||||
def available?
|
||||
Canvas::Cassandra::Database.configured?(database_name)
|
||||
end
|
||||
|
||||
def on_insert(&callback)
|
||||
add_callback(:insert, callback)
|
||||
end
|
||||
|
||||
def insert(record)
|
||||
execute(:insert, record)
|
||||
if available?
|
||||
execute(:insert, record)
|
||||
record
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def on_update(&callback)
|
||||
|
@ -47,14 +56,19 @@ class EventStream
|
|||
end
|
||||
|
||||
def update(record)
|
||||
execute(:update, record)
|
||||
if available?
|
||||
execute(:update, record)
|
||||
record
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def fetch(ids)
|
||||
rows = []
|
||||
if ids.present?
|
||||
if available? && ids.present?
|
||||
database.execute(fetch_cql, ids).fetch do |row|
|
||||
rows << record_type.from_attributes(row)
|
||||
rows << record_type.from_attributes(row.to_hash)
|
||||
end
|
||||
end
|
||||
rows
|
||||
|
@ -66,7 +80,7 @@ class EventStream
|
|||
on_insert do |record|
|
||||
if entry = index.entry_proc.call(record)
|
||||
key = index.key_proc ? index.key_proc.call(entry) : entry
|
||||
index.insert(record.id, key, record.created_at)
|
||||
index.insert(record, key)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -90,8 +104,8 @@ class EventStream
|
|||
"#{database_name}.#{table}"
|
||||
end
|
||||
|
||||
def ttl_seconds(created_at)
|
||||
((created_at + time_to_live) - Time.now).to_i
|
||||
def ttl_seconds(record)
|
||||
((record.created_at + time_to_live) - Time.now).to_i
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -106,7 +120,7 @@ class EventStream
|
|||
end
|
||||
|
||||
def execute(operation, record)
|
||||
ttl_seconds = self.ttl_seconds(record.created_at)
|
||||
ttl_seconds = self.ttl_seconds(record)
|
||||
return if ttl_seconds < 0
|
||||
|
||||
database.batch do
|
||||
|
|
|
@ -52,30 +52,46 @@ class EventStream::Index
|
|||
time.to_i - (time.to_i % bucket_size)
|
||||
end
|
||||
|
||||
def insert(id, key, timestamp)
|
||||
ttl_seconds = event_stream.ttl_seconds(timestamp)
|
||||
def bookmark_for(record)
|
||||
prefix = record.id.to_s[0,8]
|
||||
bucket = bucket_for_time(record.created_at)
|
||||
ordered_id = "#{record.created_at.to_i}/#{prefix}"
|
||||
[bucket, ordered_id]
|
||||
end
|
||||
|
||||
def insert(record, key)
|
||||
ttl_seconds = event_stream.ttl_seconds(record)
|
||||
return if ttl_seconds < 0
|
||||
|
||||
prefix = id.to_s[0,8]
|
||||
bucket = bucket_for_time(timestamp)
|
||||
bucket, ordered_id = bookmark_for(record)
|
||||
key = "#{key}/#{bucket}"
|
||||
ordered_id = "#{timestamp.to_i}/#{prefix}"
|
||||
database.update(insert_cql, key, ordered_id, id, ttl_seconds)
|
||||
database.update(insert_cql, key, ordered_id, record.id, ttl_seconds)
|
||||
end
|
||||
|
||||
def for_key(key)
|
||||
shard = Shard.current
|
||||
BookmarkedCollection.build(EventStream::Index::Bookmarker) do |pager|
|
||||
shard.activate { history(key, pager) }
|
||||
bookmarker = EventStream::Index::Bookmarker.new(self)
|
||||
BookmarkedCollection.build(bookmarker) do |pager|
|
||||
if event_stream.available?
|
||||
shard.activate { history(key, pager) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module Bookmarker
|
||||
def self.bookmark_for(item)
|
||||
[item['bucket'], item['ordered_id']]
|
||||
class Bookmarker
|
||||
def initialize(index)
|
||||
@index = index
|
||||
end
|
||||
|
||||
def self.validate(bookmark)
|
||||
def bookmark_for(item)
|
||||
if item.is_a?(@index.event_stream.record_type)
|
||||
@index.bookmark_for(item)
|
||||
else
|
||||
[item['bucket'], item['ordered_id']]
|
||||
end
|
||||
end
|
||||
|
||||
def validate(bookmark)
|
||||
bookmark.is_a?(Array) && bookmark.size == 2
|
||||
end
|
||||
end
|
||||
|
@ -123,9 +139,6 @@ class EventStream::Index
|
|||
end
|
||||
end
|
||||
|
||||
# possible optimization: query page_views_counters_by_context_and_day ,
|
||||
# and use it as a secondary index to skip days where the user didn't
|
||||
# have any page views
|
||||
ordered_id = nil
|
||||
bucket -= bucket_size
|
||||
end
|
||||
|
|
|
@ -0,0 +1,365 @@
|
|||
#
|
||||
# Copyright (C) 2013 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../cassandra_spec_helper')
|
||||
|
||||
describe "AuthenticationAudit API", type: :integration do
|
||||
it_should_behave_like "cassandra audit logs"
|
||||
|
||||
before do
|
||||
@viewing_user = site_admin_user
|
||||
@account = Account.default
|
||||
user_with_pseudonym(active_all: true)
|
||||
@event = Auditors::Authentication.record(@pseudonym, 'login')
|
||||
end
|
||||
|
||||
def fetch_for_context(context, options={})
|
||||
type = context.class.to_s.downcase
|
||||
id = context.id.to_s
|
||||
|
||||
path = "/api/v1/audit/authentication/#{type.pluralize}/#{id}"
|
||||
arguments = { controller: 'authentication_audit_api', action: "for_#{type}", :"#{type}_id" => id, format: 'json' }
|
||||
if per_page = options.delete(:per_page)
|
||||
path += "?per_page=#{per_page}"
|
||||
arguments[:per_page] = per_page.to_s
|
||||
end
|
||||
|
||||
api_call_as_user(@viewing_user, :get, path, arguments, {}, {}, options.slice(:expected_status))
|
||||
end
|
||||
|
||||
def expect_event_for_context(context, event)
|
||||
json = fetch_for_context(context)
|
||||
json['events'].map{ |e| [Shard.global_id_for(e['pseudonym_id']), e['event_type']] }.
|
||||
should include([event.pseudonym_id, event.event_type])
|
||||
end
|
||||
|
||||
def forbid_event_for_context(context, event)
|
||||
json = fetch_for_context(context)
|
||||
json['events'].map{ |e| [e['pseudonym_id'], e['event_type']] }.
|
||||
should_not include([event.pseudonym_id, event.event_type])
|
||||
end
|
||||
|
||||
describe "formatting" do
|
||||
before do
|
||||
@json = fetch_for_context(@user)
|
||||
end
|
||||
|
||||
it "should have a meta key with primaryCollection=events" do
|
||||
@json['meta']['primaryCollection'].should == 'events'
|
||||
end
|
||||
|
||||
describe "events collection" do
|
||||
before do
|
||||
@json = @json['events']
|
||||
end
|
||||
|
||||
it "should be formatted as an array of AuthenticationEvent objects" do
|
||||
@json.should == [{
|
||||
"created_at" => @event.created_at.in_time_zone.iso8601,
|
||||
"event_type" => @event.event_type,
|
||||
"pseudonym_id" => @pseudonym.id,
|
||||
"account_id" => @account.id,
|
||||
"user_id" => @user.id
|
||||
}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "pseudonyms collection" do
|
||||
before do
|
||||
@json = @json['pseudonyms']
|
||||
end
|
||||
|
||||
it "should be formatted as an array of Pseudonym objects" do
|
||||
@json.should == [{
|
||||
"id" => @pseudonym.id,
|
||||
"account_id" => @account.id,
|
||||
"user_id" => @user.id,
|
||||
"unique_id" => @pseudonym.unique_id,
|
||||
"sis_user_id" => nil
|
||||
}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "accounts collection" do
|
||||
before do
|
||||
@json = @json['accounts']
|
||||
end
|
||||
|
||||
it "should be formatted as an array of Account objects" do
|
||||
@json.should == [{
|
||||
"id" => @account.id,
|
||||
"name" => @account.name,
|
||||
"parent_account_id" => nil,
|
||||
"root_account_id" => nil,
|
||||
"default_time_zone" => @account.default_time_zone,
|
||||
"default_storage_quota_mb" => @account.default_storage_quota_mb,
|
||||
"default_user_storage_quota_mb" => @account.default_user_storage_quota_mb,
|
||||
"default_group_storage_quota_mb" => @account.default_group_storage_quota_mb
|
||||
}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "users collection" do
|
||||
before do
|
||||
@json = @json['users']
|
||||
end
|
||||
|
||||
it "should be formatted as an array of User objects" do
|
||||
@json.should == [{
|
||||
"id" => @user.id,
|
||||
"name" => @user.name,
|
||||
"sortable_name" => @user.sortable_name,
|
||||
"short_name" => @user.short_name,
|
||||
"login_id" => @pseudonym.unique_id
|
||||
}]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "nominal cases" do
|
||||
it "should include events at pseudonym endpoint" do
|
||||
expect_event_for_context(@pseudonym, @event)
|
||||
end
|
||||
|
||||
it "should include events at account endpoint" do
|
||||
expect_event_for_context(@account, @event)
|
||||
end
|
||||
|
||||
it "should include events at user endpoint" do
|
||||
expect_event_for_context(@user, @event)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a second account (same user)" do
|
||||
before do
|
||||
@account = account_model
|
||||
user_with_pseudonym(user: @user, account: @account, active_all: true)
|
||||
end
|
||||
|
||||
it "should not include cross-account events at pseudonym endpoint" do
|
||||
forbid_event_for_context(@pseudonym, @event)
|
||||
end
|
||||
|
||||
it "should not include cross-account events at account endpoint" do
|
||||
forbid_event_for_context(@account, @event)
|
||||
end
|
||||
|
||||
it "should include cross-account events at user endpoint" do
|
||||
expect_event_for_context(@user, @event)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a second user (same account)" do
|
||||
before do
|
||||
user_with_pseudonym(active_all: true)
|
||||
end
|
||||
|
||||
it "should not include cross-user events at pseudonym endpoint" do
|
||||
forbid_event_for_context(@pseudonym, @event)
|
||||
end
|
||||
|
||||
it "should include cross-user events at account endpoint" do
|
||||
expect_event_for_context(@account, @event)
|
||||
end
|
||||
|
||||
it "should not include cross-user events at user endpoint" do
|
||||
forbid_event_for_context(@user, @event)
|
||||
end
|
||||
end
|
||||
|
||||
context "deleted entities" do
|
||||
it "should 404 for inactive pseudonyms" do
|
||||
@pseudonym.destroy
|
||||
fetch_for_context(@pseudonym, expected_status: 404)
|
||||
end
|
||||
|
||||
it "should 404 for inactive accounts" do
|
||||
# can't just delete Account.default
|
||||
@account = account_model
|
||||
@account.destroy
|
||||
fetch_for_context(@account, expected_status: 404)
|
||||
end
|
||||
|
||||
it "should 404 for inactive users" do
|
||||
@user.destroy
|
||||
fetch_for_context(@user, expected_status: 404)
|
||||
end
|
||||
end
|
||||
|
||||
describe "permissions" do
|
||||
before do
|
||||
@user, @viewing_user = @user, user_model
|
||||
end
|
||||
|
||||
context "no permission on account" do
|
||||
it "should not authorize the pseudonym endpoint" do
|
||||
fetch_for_context(@pseudonym, expected_status: 401)
|
||||
end
|
||||
|
||||
it "should not authorize the account endpoint" do
|
||||
fetch_for_context(@account, expected_status: 401)
|
||||
end
|
||||
|
||||
it "should not authorize the user endpoint" do
|
||||
fetch_for_context(@user, expected_status: 401)
|
||||
end
|
||||
end
|
||||
|
||||
context "with :view_statistics permission on account" do
|
||||
before do
|
||||
@user, _ = @user, account_admin_user_with_role_changes(
|
||||
:account => @account, :user => @viewing_user,
|
||||
:membership_type => 'CustomAdmin',
|
||||
:role_changes => {:view_statistics => true})
|
||||
end
|
||||
|
||||
it "should authorize the pseudonym endpoint" do
|
||||
fetch_for_context(@pseudonym, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the account endpoint" do
|
||||
fetch_for_context(@account, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the user endpoint" do
|
||||
fetch_for_context(@user, expected_status: 200)
|
||||
end
|
||||
end
|
||||
|
||||
context "with :manage_user_logins permission on account" do
|
||||
before do
|
||||
@user, _ = @user, account_admin_user_with_role_changes(
|
||||
:account => @account, :user => @viewing_user,
|
||||
:membership_type => 'CustomAdmin',
|
||||
:role_changes => {:manage_user_logins => true})
|
||||
end
|
||||
|
||||
it "should authorize the pseudonym endpoint" do
|
||||
fetch_for_context(@pseudonym, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the account endpoint" do
|
||||
fetch_for_context(@account, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the user endpoint" do
|
||||
fetch_for_context(@user, expected_status: 200)
|
||||
end
|
||||
end
|
||||
|
||||
context "with :view_statistics permission on site admin account" do
|
||||
before do
|
||||
@user, _ = @user, account_admin_user_with_role_changes(
|
||||
:account => Account.site_admin, :user => @viewing_user,
|
||||
:membership_type => 'CustomAdmin',
|
||||
:role_changes => {:view_statistics => true})
|
||||
end
|
||||
|
||||
it "should authorize the pseudonym endpoint" do
|
||||
fetch_for_context(@pseudonym, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the account endpoint" do
|
||||
fetch_for_context(@account, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the user endpoint" do
|
||||
fetch_for_context(@user, expected_status: 200)
|
||||
end
|
||||
end
|
||||
|
||||
context "with :manage_user_logins permission on site admin account" do
|
||||
before do
|
||||
@user, _ = @user, account_admin_user_with_role_changes(
|
||||
:account => Account.site_admin, :user => @viewing_user,
|
||||
:membership_type => 'CustomAdmin',
|
||||
:role_changes => {:manage_user_logins => true})
|
||||
end
|
||||
|
||||
it "should authorize the pseudonym endpoint" do
|
||||
fetch_for_context(@pseudonym, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the account endpoint" do
|
||||
fetch_for_context(@account, expected_status: 200)
|
||||
end
|
||||
|
||||
it "should authorize the user endpoint" do
|
||||
fetch_for_context(@user, expected_status: 200)
|
||||
end
|
||||
end
|
||||
|
||||
describe "per-account permissions when fetching by user" do
|
||||
before do
|
||||
@account = account_model
|
||||
user_with_pseudonym(user: @user, account: @account, active_all: true)
|
||||
@user, _ = @user, account_admin_user_with_role_changes(
|
||||
:account => @account, :user => @viewing_user,
|
||||
:membership_type => 'CustomAdmin',
|
||||
:role_changes => {:manage_user_logins => true})
|
||||
end
|
||||
|
||||
context "without permission on the second account" do
|
||||
it "should not include cross-account events at user endpoint" do
|
||||
forbid_event_for_context(@user, @event)
|
||||
end
|
||||
end
|
||||
|
||||
context "with permission on the site admin account" do
|
||||
before do
|
||||
@user, _ = @user, account_admin_user_with_role_changes(
|
||||
:account => Account.site_admin, :user => @viewing_user,
|
||||
:membership_type => 'CustomAdmin',
|
||||
:role_changes => {:manage_user_logins => true})
|
||||
end
|
||||
|
||||
it "should include cross-account events at user endpoint" do
|
||||
expect_event_for_context(@user, @event)
|
||||
end
|
||||
end
|
||||
|
||||
context "when viewing self" do
|
||||
before do
|
||||
@viewing_user = @user
|
||||
end
|
||||
|
||||
it "should include cross-account events at user endpoint" do
|
||||
expect_event_for_context(@user, @event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "pagination" do
|
||||
before do
|
||||
# 3 events total
|
||||
Auditors::Authentication.record(@pseudonym, 'logout')
|
||||
Auditors::Authentication.record(@pseudonym, 'login')
|
||||
@json = fetch_for_context(@user, :per_page => 2)
|
||||
end
|
||||
|
||||
it "should only return one page of results" do
|
||||
@json['events'].size.should == 2
|
||||
end
|
||||
|
||||
it "should have pagination headers" do
|
||||
response.headers['Link'].should match(/rel="next"/)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -27,3 +27,11 @@ shared_examples_for "cassandra page views" do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples_for "cassandra audit logs" do
|
||||
before do
|
||||
unless Canvas::Cassandra::Database.configured?('auditors')
|
||||
pending "needs cassandra auditors configuration"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -771,7 +771,7 @@ describe PseudonymSessionsController do
|
|||
|
||||
@user.otp_secret_key = ROTP::Base32.random_base32
|
||||
@user.save!
|
||||
user_session(@user)
|
||||
user_session(@user, @pseudonym)
|
||||
session[:pending_otp] = true
|
||||
end
|
||||
|
||||
|
@ -812,7 +812,7 @@ describe PseudonymSessionsController do
|
|||
|
||||
context "enrollment" do
|
||||
before do
|
||||
user_session(@user)
|
||||
user_session(@user, @pseudonym)
|
||||
end
|
||||
|
||||
it "should generate a secret key" do
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
#
|
||||
# Copyright (C) 2013 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../sharding_spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../cassandra_spec_helper')
|
||||
|
||||
describe AuthenticationAuditApiController do
|
||||
it_should_behave_like "cassandra audit logs"
|
||||
|
||||
before do
|
||||
@account = Account.default
|
||||
user_with_pseudonym(active_all: true)
|
||||
@event = Auditors::Authentication.record(@pseudonym, 'login')
|
||||
end
|
||||
|
||||
context "nominal cases" do
|
||||
it "should include event for pseudonym" do
|
||||
Auditors::Authentication.for_pseudonym(@pseudonym).paginate(:per_page => 1).
|
||||
should include(@event)
|
||||
end
|
||||
|
||||
it "should include event for account" do
|
||||
Auditors::Authentication.for_account(@account).paginate(:per_page => 1).
|
||||
should include(@event)
|
||||
end
|
||||
|
||||
it "should include event at user" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 1).
|
||||
should include(@event)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a second account (same user)" do
|
||||
before do
|
||||
@account = account_model
|
||||
user_with_pseudonym(user: @user, account: @account, active_all: true)
|
||||
end
|
||||
|
||||
it "should not include cross-account events for pseudonym" do
|
||||
Auditors::Authentication.for_pseudonym(@pseudonym).paginate(:per_page => 1).
|
||||
should_not include(@event)
|
||||
end
|
||||
|
||||
it "should not include cross-account events for account" do
|
||||
Auditors::Authentication.for_account(@account).paginate(:per_page => 1).
|
||||
should_not include(@event)
|
||||
end
|
||||
|
||||
it "should include cross-account events for user" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 1).
|
||||
should include(@event)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a second user (same account)" do
|
||||
before do
|
||||
user_with_pseudonym(active_all: true)
|
||||
end
|
||||
|
||||
it "should not include cross-user events for pseudonym" do
|
||||
Auditors::Authentication.for_pseudonym(@pseudonym).paginate(:per_page => 1).
|
||||
should_not include(@event)
|
||||
end
|
||||
|
||||
it "should include cross-user events for account" do
|
||||
Auditors::Authentication.for_account(@account).paginate(:per_page => 1).
|
||||
should include(@event)
|
||||
end
|
||||
|
||||
it "should not include cross-user events for user" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 1).
|
||||
should_not include(@event)
|
||||
end
|
||||
end
|
||||
|
||||
describe "sharding" do
|
||||
specs_require_sharding
|
||||
|
||||
context "different shard, same database server" do
|
||||
before do
|
||||
@shard1.activate do
|
||||
@account = account_model
|
||||
user_with_pseudonym(account: @account, active_all: true)
|
||||
@event1 = Auditors::Authentication.record(@pseudonym, 'login')
|
||||
end
|
||||
user_with_pseudonym(user: @user, active_all: true)
|
||||
@event2 = Auditors::Authentication.record(@pseudonym, 'login')
|
||||
end
|
||||
|
||||
it "should include events from the user's native shard" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
|
||||
should include(@event1)
|
||||
end
|
||||
|
||||
it "should include events from the other pseudonym's shard" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
|
||||
should include(@event2)
|
||||
end
|
||||
|
||||
it "should not include duplicate events" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 4).
|
||||
size.should == 2
|
||||
end
|
||||
end
|
||||
|
||||
context "different shard, different database server" do
|
||||
before do
|
||||
@shard2.activate do
|
||||
@account = account_model
|
||||
user_with_pseudonym(account: @account, active_all: true)
|
||||
@event1 = Auditors::Authentication.record(@pseudonym, 'login')
|
||||
end
|
||||
user_with_pseudonym(user: @user, active_all: true)
|
||||
@event2 = Auditors::Authentication.record(@pseudonym, 'login')
|
||||
end
|
||||
|
||||
it "should include events from the user's native shard" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
|
||||
should include(@event1)
|
||||
end
|
||||
|
||||
it "should include events from the other pseudonym's shard" do
|
||||
Auditors::Authentication.for_user(@user).paginate(:per_page => 2).
|
||||
should include(@event2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -25,7 +25,11 @@ describe EventStream::Index do
|
|||
def @database.update_record(*args); end
|
||||
def @database.update(*args); end
|
||||
|
||||
@stream = stub('stream', :database => @database, :ttl_seconds => 1.year)
|
||||
@stream = stub('stream',
|
||||
:database => @database,
|
||||
:available? => true,
|
||||
:record_type => EventStream::Record,
|
||||
:ttl_seconds => 1.year)
|
||||
end
|
||||
|
||||
context "setup block" do
|
||||
|
@ -173,16 +177,17 @@ describe EventStream::Index do
|
|||
@id = stub('id', :to_s => '1234567890')
|
||||
@key = stub('key', :to_s => 'key_value')
|
||||
@timestamp = stub('timestamp', :to_i => 12345)
|
||||
@record = stub('record', :id => @id, :created_at => @timestamp)
|
||||
end
|
||||
|
||||
it "should use the stream's database" do
|
||||
@database.expects(:update).once
|
||||
@index.insert(@id, @key, @timestamp)
|
||||
@index.insert(@record, @key)
|
||||
end
|
||||
|
||||
it "should use the configured table" do
|
||||
@database.expects(:update).once.with(regexp_matches(/ INTO #{@table} /), anything, anything, anything, anything)
|
||||
@index.insert(@id, @key, @timestamp)
|
||||
@index.insert(@record, @key)
|
||||
end
|
||||
|
||||
it "should combine the key and timestamp bucket into the configured key column" do
|
||||
|
@ -191,25 +196,25 @@ describe EventStream::Index do
|
|||
@index.key_column key_column
|
||||
@index.expects(:bucket_for_time).once.with(@timestamp).returns(bucket)
|
||||
@database.expects(:update).once.with(regexp_matches(/\(#{key_column}, /), "#{@key}/#{bucket}", anything, anything, anything)
|
||||
@index.insert(@id, @key, @timestamp)
|
||||
@index.insert(@record, @key)
|
||||
end
|
||||
|
||||
it "should take a prefix off the id and the bucket for the ordered_id" do
|
||||
prefix = @id.to_s[0,8]
|
||||
@database.expects(:update).once.with(regexp_matches(/, ordered_id,/), anything, "#{@timestamp.to_i}/#{prefix}", anything, anything)
|
||||
@index.insert(@id, @key, @timestamp)
|
||||
@index.insert(@record, @key)
|
||||
end
|
||||
|
||||
it "should pass through the id into the configured id column" do
|
||||
id_column = stub(:to_s => "expected_id_column")
|
||||
@index.id_column id_column
|
||||
@database.expects(:update).once.with(regexp_matches(/, #{id_column}\)/), anything, anything, @id, anything)
|
||||
@index.insert(@id, @key, @timestamp)
|
||||
@index.insert(@record, @key)
|
||||
end
|
||||
|
||||
it "should include the ttl" do
|
||||
@database.expects(:update).once.with(regexp_matches(/ USING TTL /), anything, anything, anything, @stream.ttl_seconds(@timestamp))
|
||||
@index.insert(@id, @key, @timestamp)
|
||||
@database.expects(:update).once.with(regexp_matches(/ USING TTL /), anything, anything, anything, @stream.ttl_seconds(@record))
|
||||
@index.insert(@record, @key)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -219,14 +224,9 @@ describe EventStream::Index do
|
|||
@index.scrollback_default @index.bucket_size
|
||||
@pager = @index.for_key('key')
|
||||
|
||||
type = Struct.new(:id)
|
||||
@ids = (1..4).to_a
|
||||
@typed_results = @ids.map{ |id| type.new(id) }
|
||||
@raw_results = @ids.map{ |id| { 'id' => id, 'ordered_id' => id } }
|
||||
end
|
||||
|
||||
it "should return a bookmarked collection" do
|
||||
@pager.should be_a BookmarkedCollection::Proxy
|
||||
@typed_results = @ids.map{ |id| @stream.record_type.new('id' => id, 'created_at' => id.minutes.ago) }
|
||||
@raw_results = @typed_results.map{ |record| { 'id' => record.id, 'ordered_id' => "#{record.created_at.to_i}/#{record.id}" } }
|
||||
end
|
||||
|
||||
def setup_fetch(start, requested)
|
||||
|
@ -236,6 +236,16 @@ describe EventStream::Index do
|
|||
@stream.expects(:fetch).once.with(@ids[start, requested]).returns(@typed_results[start, requested])
|
||||
end
|
||||
|
||||
it "should return a bookmarked collection" do
|
||||
@pager.should be_a BookmarkedCollection::Proxy
|
||||
end
|
||||
|
||||
it "should be able to get bookmark from a typed item" do
|
||||
setup_fetch(0, 2)
|
||||
page = @pager.paginate(:per_page => 2)
|
||||
page.bookmark_for(page.last).should == page.next_bookmark
|
||||
end
|
||||
|
||||
context "one page of results" do
|
||||
before do
|
||||
setup_fetch(0, 4)
|
||||
|
|
|
@ -27,6 +27,7 @@ describe EventStream do
|
|||
def @database.insert_record(*args); end
|
||||
def @database.update(*args); end
|
||||
::Canvas::Cassandra::Database.stubs(:from_config).with(@database_name.to_s).returns(@database)
|
||||
::Canvas::Cassandra::Database.stubs(:configured?).with(@database_name.to_s).returns(true)
|
||||
end
|
||||
|
||||
context "setup block" do
|
||||
|
@ -152,7 +153,7 @@ describe EventStream do
|
|||
end
|
||||
|
||||
it "should set the record's ttl" do
|
||||
@database.expects(:insert_record).with(anything, anything, anything, @stream.ttl_seconds(@record.created_at))
|
||||
@database.expects(:insert_record).with(anything, anything, anything, @stream.ttl_seconds(@record))
|
||||
@stream.insert(@record)
|
||||
end
|
||||
|
||||
|
@ -223,7 +224,7 @@ describe EventStream do
|
|||
end
|
||||
|
||||
it "should set the record's ttl" do
|
||||
@database.expects(:update_record).with(anything, anything, anything, @stream.ttl_seconds(@record.created_at))
|
||||
@database.expects(:update_record).with(anything, anything, anything, @stream.ttl_seconds(@record))
|
||||
@stream.update(@record)
|
||||
end
|
||||
|
||||
|
@ -268,11 +269,12 @@ describe EventStream do
|
|||
it "should map the returned rows to the configured record type" do
|
||||
record_type = stub('record_type')
|
||||
raw_result = stub('raw_result')
|
||||
cql_result = stub('cql_result', :to_hash => raw_result)
|
||||
typed_result = stub('typed_result')
|
||||
record_type.expects(:from_attributes).with(raw_result).returns(typed_result)
|
||||
|
||||
@stream.record_type record_type
|
||||
@results.expects(:fetch).yields(raw_result)
|
||||
@results.expects(:fetch).yields(cql_result)
|
||||
@database.expects(:execute).once.returns(@results)
|
||||
results = @stream.fetch([1])
|
||||
results.should == [typed_result]
|
||||
|
@ -308,13 +310,13 @@ describe EventStream do
|
|||
)
|
||||
end
|
||||
|
||||
it "should insert the record with id and created_at" do
|
||||
@index.expects(:insert).once.with(@record.id, anything, @record.created_at)
|
||||
it "should insert the provided record into the index" do
|
||||
@index.expects(:insert).once.with(@record, anything)
|
||||
@stream.insert(@record)
|
||||
end
|
||||
|
||||
it "should translate the record through the entry_proc for the key" do
|
||||
@index.expects(:insert).once.with(anything, @entry, anything)
|
||||
@index.expects(:insert).once.with(anything, @entry)
|
||||
@stream.insert(@record)
|
||||
end
|
||||
|
||||
|
@ -326,7 +328,7 @@ describe EventStream do
|
|||
|
||||
it "should translate the result of the entry_proc through the key_proc if present" do
|
||||
@index.key_proc lambda{ |entry| entry.key }
|
||||
@index.expects(:insert).once.with(anything, @key, anything)
|
||||
@index.expects(:insert).once.with(anything, @key)
|
||||
@stream.insert(@record)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue