Fixes #2074 - Ability of deleting customers and / or all ticket at once.

This commit is contained in:
Rolf Schmidt 2020-09-08 17:06:23 +02:00 committed by Thorsten Eckel
parent 8749190a51
commit 3fb9b05027
60 changed files with 1684 additions and 163 deletions

View file

@ -12,6 +12,7 @@ class App.ControllerGenericNew extends App.ControllerModal
params: @item
screen: @screen || 'edit'
autofocus: true
handlers: @handlers
)
@controller.form
@ -57,10 +58,11 @@ class App.ControllerGenericEdit extends App.ControllerModal
@head = @pageData.head || @pageData.object
@controller = new App.ControllerForm(
model: App[ @genericObject ]
params: @item
screen: @screen || 'edit'
autofocus: true
model: App[ @genericObject ]
params: @item
screen: @screen || 'edit'
autofocus: true
handlers: @handlers
)
@controller.form

View file

@ -0,0 +1,267 @@
class Index extends App.ControllerSubContent
requiredPermission: 'admin.data_privacy'
header: 'Data Privacy'
events:
'click .js-new': 'new'
'click .js-description': 'description'
'click .js-toggle-tickets': 'toggleTickets'
constructor: ->
super
@load()
@subscribeDataPrivacyTaskId = App.DataPrivacyTask.subscribe(@render)
load: =>
callback = =>
@stopLoading()
@render()
@startLoading()
App.DataPrivacyTask.fetchFull(
callback
clear: true
)
show: (params) =>
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
if params.integration
# we reuse the integration parameter
# because there is no own route possible
# (see manage.coffee)
@user_id = params.integration
@navigate '#system/data_privacy'
return
if @user_id
@new(false, @user_id)
@user_id = undefined
render: =>
runningTasks = App.DataPrivacyTask.search(
filter:
state: 'in process'
order: 'DESC'
)
runningTasksHTML = App.view('data_privacy/tasks')(
tasks: runningTasks
)
failedTasks = App.DataPrivacyTask.search(
filter:
state: 'failed'
order: 'DESC'
)
failedTasksHTML = App.view('data_privacy/tasks')(
tasks: failedTasks
)
completedTasks = App.DataPrivacyTask.search(
filter:
state: 'completed'
order: 'DESC'
)
completedTasksHTML = App.view('data_privacy/tasks')(
tasks: completedTasks
)
# show description button, only if content exists
description = marked(App.DataPrivacyTask.description)
@html App.view('data_privacy/index')(
taskCount: ( runningTasks.length + failedTasks.length + completedTasks.length )
runningTaskCount: runningTasks.length
failedTaskCount: failedTasks.length
completedTaskCount: completedTasks.length
runningTasksHTML: runningTasksHTML
failedTasksHTML: failedTasksHTML
completedTasksHTML: completedTasksHTML
description: description
)
release: =>
if @subscribeDataPrivacyTaskId
App.DataPrivacyTask.unsubscribe(@subscribeDataPrivacyTaskId)
new: (e, user_id = undefined) ->
if e
e.preventDefault()
new TaskNew(
pageData:
head: 'Deletion Task'
title: 'Deletion Task'
object: 'DataPrivacyTask'
objects: 'DataPrivacyTasks'
genericObject: 'DataPrivacyTask'
container: @el.closest('.content')
callback: @load
large: true
handlers: [@formHandler]
item:
'deletable_id': user_id
)
toggleTickets: (e) ->
e.preventDefault()
id = $(e.target).data('id')
type = $(e.target).data('type')
expanded = $(e.target).hasClass('expanded')
return if !id
new_expanded = ''
text = 'See more'
if !expanded
new_expanded = ' expanded'
text = 'See less'
task = App.DataPrivacyTask.find(id)
list = clone(task.preferences[type])
if expanded
list = list.slice(0, 50)
list.push('...')
list = list.join(', ')
$(e.target).closest('div.ticket-list').html(list + ' <br><div class="btn btn--text js-toggle-tickets' + new_expanded + '" data-type="' + type + '" data-id="' + id + '">' + App.i18n.translateInline(text) + '</div>')
description: (e) =>
new App.ControllerGenericDescription(
description: App.DataPrivacyTask.description
container: @el.closest('.content')
)
formHandler: (params, attribute, attributes, classname, form, ui) ->
return if !attribute
userID = params['deletable_id']
if userID
$('body').find('.js-TaskNew').removeClass('hidden')
else
$('body').find('.js-TaskNew').addClass('hidden')
form.find('.js-preview').remove()
return if !userID
conditionCustomer =
'condition':
'ticket.customer_id':
'operator': 'is'
'pre_condition':'specific'
'value': userID
conditionOwner =
'condition':
'ticket.owner_id':
'operator': 'is'
'pre_condition':'specific'
'value': userID
App.Ajax.request(
id: 'ticket_selector'
type: 'POST'
url: "#{App.Config.get('api_path')}/tickets/selector"
data: JSON.stringify(conditionCustomer)
processData: true,
success: (dataCustomer, status, xhr) ->
App.Collection.loadAssets(dataCustomer.assets)
App.Ajax.request(
id: 'ticket_selector'
type: 'POST'
url: "#{App.Config.get('api_path')}/tickets/selector"
data: JSON.stringify(conditionOwner)
processData: true,
success: (dataOwner, status, xhr) ->
App.Collection.loadAssets(dataOwner.assets)
user = App.User.find(userID)
deleteOrganization = ''
if user.organization_id
organization = App.Organization.find(user.organization_id)
if organization && organization.member_ids.length < 2
attribute = { name: 'preferences::delete_organization', display: 'Delete organization?', tag: 'boolean', default: true, translate: true }
deleteOrganization = ui.formGenItem(attribute, classname, form).html()
sure_attribute = { name: 'preferences::sure', display: 'Are you sure?', tag: 'input', translate: false, placeholder: App.i18n.translateInline('delete').toUpperCase() }
sureInput = ui.formGenItem(sure_attribute, classname, form).html()
preview_html = App.view('data_privacy/preview')(
customer_count: dataCustomer.ticket_count || 0
owner_count: dataOwner.ticket_count || 0
delete_organization_html: deleteOrganization
sure_html: sureInput
user_id: userID
)
if form.find('.js-preview').length < 1
form.append(preview_html)
else
form.find('.js-preview').replaceWith(preview_html)
new App.TicketList(
tableId: 'ticket-selector'
el: form.find('.js-previewTableCustomer')
ticket_ids: dataCustomer.ticket_ids
)
new App.TicketList(
tableId: 'ticket-selector'
el: form.find('.js-previewTableOwner')
ticket_ids: dataOwner.ticket_ids
)
)
)
class TaskNew extends App.ControllerGenericNew
buttonSubmit: 'Delete'
buttonClass: 'btn--danger js-TaskNew hidden'
content: ->
if @item['deletable_id']
@buttonClass = 'btn--danger js-TaskNew'
else
@buttonClass = 'btn--danger js-TaskNew hidden'
super
onSubmit: (e) ->
params = @formParam(e.target)
params['deletable_type'] = 'User'
object = new App[ @genericObject ]
object.load(params)
# validate
errors = object.validate()
if params['preferences']['sure'] isnt App.i18n.translateInline('delete').toUpperCase()
if !errors
errors = {}
errors['preferences::sure'] = 'invalid'
if errors
@log 'error', errors
@formValidate( form: e.target, errors: errors )
return false
# disable form
@formDisable(e)
# save object
ui = @
object.save(
done: ->
if ui.callback
item = App[ ui.genericObject ].fullLocal(@id)
ui.callback(item)
ui.close()
fail: (settings, details) ->
ui.log 'errors', details
ui.formEnable(e)
ui.controller.showAlert(details.error_human || details.error || 'Unable to create object!')
)
App.Config.set('DataPrivacy', { prio: 3600, name: 'Data Privacy', parent: '#system', target: '#system/data_privacy', controller: Index, permission: ['admin.data_privacy'] }, 'NavBarAdmin')

View file

@ -30,4 +30,4 @@ App.Config.set('system/:target/:integration', ManageRouter, 'Routes')
App.Config.set('Manage', { prio: 1000, name: 'Manage', target: '#manage', permission: ['admin.*'] }, 'NavBarAdmin')
App.Config.set('Channels', { prio: 2500, name: 'Channels', target: '#channels', permission: ['admin.*'] }, 'NavBarAdmin')
App.Config.set('Settings', { prio: 7000, name: 'Settings', target: '#settings', permission: ['admin.*'] }, 'NavBarAdmin')
App.Config.set('System', { prio: 8000, name: 'System', target: '#system', permission: ['admin.*'] }, 'NavBarAdmin')
App.Config.set('System', { prio: 8000, name: 'System', target: '#system', permission: ['admin.*'] }, 'NavBarAdmin')

View file

@ -14,6 +14,17 @@ class App.TaskbarWidget extends App.CollectionController
constructor: ->
super
App.Event.bind(
'Taskbar:destroy'
(data, event) =>
task = App.Taskbar.find(data.id)
return if !task
return if !task.key
@removeTask(task.key)
'Collection::Subscribe::Taskbar'
)
dndOptions =
tolerance: 'pointer'
distance: 15
@ -80,6 +91,10 @@ class App.TaskbarWidget extends App.CollectionController
event: e
)
return
@removeTask(key)
removeTask: (key = false) =>
return if !key
# check if active task is closed
currentTask = App.TaskManager.get(key)

View file

@ -25,6 +25,15 @@ class SidebarCustomer extends App.Controller
name: 'customer-edit'
callback: @editCustomer
}
if @permissionCheck('admin.data_privacy')
@item.sidebarActions.push {
title: 'Delete Customer'
name: 'customer-delete'
callback: =>
@navigate "#system/data_privacy/#{@ticket.customer_id}"
}
@item
metaBadge: (user) =>

View file

@ -149,6 +149,14 @@ class ActionRow extends App.ObserverActionRow
callback: @resendVerificationEmail
})
if @permissionCheck('admin.data_privacy')
actions.push {
title: 'Delete'
name: 'delete'
callback: =>
@navigate "#system/data_privacy/#{user.id}"
}
actions
class Object extends App.ObserverController

View file

@ -55,28 +55,6 @@ class Index extends App.ControllerSubContent
renderResult: (user_ids = []) ->
@stopLoading()
callbackHeader = (header) ->
attribute =
name: 'switch_to'
display: 'Action'
className: 'actionCell'
translation: true
width: '250px'
displayWidth: 250
unresizable: true
header.push attribute
header
callbackAttributes = (value, object, attribute, header) ->
text = App.i18n.translateInline('View from user\'s perspective')
value = ' '
attribute.raw = ' <span class="btn btn--primary btn--small btn--slim switchView" title="' + text + '">' + App.Utils.icon('switchView') + '<span>' + text + '</span></span>'
attribute.class = ''
attribute.parentClass = 'actionCell no-padding'
attribute.link = ''
attribute.title = App.i18n.translateInline('Switch to')
value
switchTo = (id,e) =>
e.preventDefault()
e.stopPropagation()
@ -129,15 +107,38 @@ class Index extends App.ControllerSubContent
model: App.User
objects: users
class: 'user-list'
callbackHeader: [callbackHeader]
callbackAttributes:
switch_to: [
callbackAttributes
]
bindCol:
switch_to:
events:
'click': switchTo
customActions: [
{
name: 'switchTo'
display: 'View from user\'s perspective'
icon: 'switchView '
class: 'create js-switchTo'
callback: (id) =>
@disconnectClient()
$('#app').hide().attr('style', 'display: none!important')
@delay(
=>
App.Auth._logout(false)
@ajax(
id: 'user_switch'
type: 'GET'
url: "#{@apiPath}/sessions/switch/#{id}"
success: (data, status, xhr) =>
location = "#{window.location.protocol}//#{window.location.host}#{data.location}"
@windowReload(undefined, location)
)
800
)
}
{
name: 'delete'
display: 'Delete'
icon: 'trash'
class: 'delete'
callback: (id) =>
@navigate "#system/data_privacy/#{id}"
},
]
bindRow:
events:
'click': edit

View file

@ -0,0 +1,30 @@
class App.DataPrivacyTask extends App.Model
@configure 'DataPrivacyTask', 'name', 'state', 'deletable_id', 'deletable_type', 'preferences'
@extend Spine.Model.Ajax
@url: @apiPath + '/data_privacy_tasks'
@configure_attributes = [
{ name: 'deletable_id', display: 'User', tag: 'autocompletion_ajax', relation: 'User', do_not_log: true },
{ name: 'state', display: 'State', tag: 'input', readonly: 1 },
{ name: 'created_by_id', display: 'Created by', relation: 'User', readonly: 1 },
{ name: 'created_at', display: 'Created', tag: 'datetime', readonly: 1 },
{ name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 },
{ name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 },
]
@configure_overview = []
@description = '''
** Data Privacy **, helps you to delete and verify the removal of existing data of the system.
It can be used to delete tickets, organizations and users. The owner assignment will be unset in case the deleted user is an agent.
Data Privacy tasks will be executed every 10 minutes. The execution might take some additional time depending of the number of objects that should get deleted.
'''
activityMessage: (item) ->
if item.type is 'create'
return App.i18n.translateContent('%s created data privacy task to delete user id |%s|', item.created_by.displayName(), item.objectNative.deletable_id)
else if item.type is 'update'
return App.i18n.translateContent('%s updated data privacy task to delete user id |%s|', item.created_by.displayName(), item.objectNative.deletable_id)
else if item.type is 'completed'
return App.i18n.translateContent('%s completed data privacy task to delete user id |%s|', item.created_by.displayName(), item.objectNative.deletable_id)
return "Unknow action for (#{@objectDisplayName()}/#{item.type}), extend activityMessage() of model."

View file

@ -0,0 +1,33 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Data Privacy') %> <small><%- @T('Management') %></small></h1>
</div>
<div class="page-header-meta">
<a class="btn js-description"><%- @T('Description') %></a>
<a class="btn btn--success js-new"><%- @T('New Deletion Task') %></a>
</div>
</div>
<div class="page-content">
<% if @taskCount < 1: %>
<div class="page-description">
<%- @description %>
</div>
<% else: %>
<% if @runningTaskCount: %>
<h2><%- @T('Running Tasks') %></h2>
<%- @runningTasksHTML %>
<% end %>
<% if @failedTaskCount: %>
<div class="spacer"></div>
<h2><%- @T('Failed Tasks') %></h2>
<%- @failedTasksHTML %>
<% end %>
<% if @completedTaskCount: %>
<div class="spacer"></div>
<h2><%- @T('Completed Tasks') %></h2>
<%- @completedTasksHTML %>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,20 @@
<div class="js-preview" data-userid="<%= @user_id %>">
<div class="form-group js-deleteOrganzation">
<%- @delete_organization_html %>
</div>
<div class="form-group">
<h3><%- @T('Preview customer tickets') %> <span class="subtitle js-previewCounterContainer">(<span class="js-previewCounter"><%= @customer_count %></span> <%- @T('matches') %>)</span></h3>
<p><%- @T('Customer tickets of the user will get deleted on execution of the task. No rollback possible.') %></p>
<div class="js-previewTableCustomer"></div>
<% if @owner_count > 0: %>
<h3><%- @T('Preview owner tickets') %><span class="subtitle js-previewCounterContainer"> <span class="u-highlight js-previewCounter"><%= @owner_count %></span> <%- @T('matches') %></span></h3>
<p><%- @T('Owner tickets of the user will not get deleted. The owner will be mapped to the system user (ID 1).') %></p>
<div class="js-previewTableOwner"></div>
<% end %>
</div>
<div class="form-group js-sure">
<h3 class="danger-color"><%- @T('Warning') %></h3>
<p class="danger-color"><%- @T('There is no rollback of this deletion possible. If you are absolutely sure to do this, then type in "%s" into the input.', App.i18n.translateInline('delete').toUpperCase()) %></p>
<%- @sure_html %>
</div>
</div>

View file

@ -0,0 +1,54 @@
<% if @tasks.length > 0: %>
<% for task in @tasks: %>
<% if task.preferences.user: %>
<div class="action" data-id="<%- task.id %>">
<div class="action-row">
<div class="action-flow action-flow--noWrap">
<h2><%- @T('Delete User') %></h2>
</div>
</div>
<div class="action-flow action-flow--row">
<div class="action-block action-block--flex">
<div class="label"><%- @T('User (censored)') %>:</div>
<%= task.preferences.user.firstname %> <%= task.preferences.user.lastname %> (<%= task.preferences.user.email %>)
<% if task.preferences.user.organization && task.preferences.delete_organization: %>
<br><br>
<div class="label"><%- @T('Deleted Organization') %>:</div>
<%= task.preferences.user.organization %>
<% end %>
<br><br>
<div class="label"><%- @T('Started') %></div>
<%- @humanTime(task.created_at) %>
<br><br>
<div class="label"><%- @T('State') %></div>
<% if task.state: %><%= task.state %><% else: %><%- @T('in process') %><% end %>
<% if task.preferences.error: %> (<%= task.preferences.error %>)<% end %>
</div>
<div class="action-block action-block--flex">
<div class="label"><%- @T('Deleted tickets (%s in total)', task.preferences.customer_tickets.length) %>:</div>
<div class="ticket-list">
<% if task.preferences.customer_tickets.length > 0: %>
<%= task.preferences.customer_tickets.slice(0, 50).join(', ') %><% if task.preferences.customer_tickets.length > 50: %>, ... <br><div href="#" class="btn btn--text js-toggle-tickets" data-type="customer_tickets" data-id="<%= task.id %>"><%- @T('See more') %></div><% end %>
<% else: %>
-
<% end %>
</div>
<br><br>
<div class="label"><%- @T('Previously owned tickets (%s in total)', task.preferences.owner_tickets.length) %>:</div>
<div class="ticket-list">
<% if task.preferences.owner_tickets.length > 0: %>
<%= task.preferences.owner_tickets.slice(0, 50).join(', ') %><% if task.preferences.owner_tickets.length > 50: %>, ... <br><div href="#" class="btn btn--text js-toggle-tickets" data-type="owner_tickets" data-id="<%= task.id %>"><%- @T('See more') %></div><% end %>
<% else: %>
-
<% end %>
</div>
</div>
</div>
</div>
<% end %>
<% end %>
<% else: %>
<div class="action action--placeholder">
<%- @T('None') %>
</div>
<% end %>

View file

@ -1,6 +1,6 @@
<div class="horizontal-filters js-filter">
</div>
<div class="js-preview <% if @attribute.preview is false: %>hide<% end %>">
<h3><%- @T('Preview') %><span class="subtitle js-previewCounterContainer hide"> <span class="u-highlight js-previewCounter">?</span> <%- @T('matches') %></span> <span class="tiny loading icon js-previewLoader hide" style="margin-left: 3px;"></span></h3>
<h3><%- @T('Preview') %><span class="subtitle js-previewCounterContainer hide"> (<span class="js-previewCounter">?</span> <%- @T('matches') %></span>) <span class="tiny loading icon js-previewLoader hide" style="margin-left: 3px;"></span></h3>
<div class="js-previewTable"></div>
</div>

View file

@ -4,6 +4,7 @@ $ok-color: hsl(41,100%,49%);
$bad-color: hsl(30,93%,50%);
$superbad-color: hsl(19,90%,51%);
$ghost-color: hsl(0,0%,80%);
$danger-color: hsl(0,65%,55%);
$task-state-closed-color: $supergood-color;
$task-state-pending-color: hsl(206,7%,28%);
@ -584,15 +585,15 @@ pre code.hljs {
&--danger {
color: white;
background: hsl(0,65%,55%);
background: $danger-color;
&:active {
background: hsl(0,65%,45%);
background: darken($danger-color, 10%);
}
&.btn--secondary {
background: white;
color: hsl(0,65%,55%);
color: $danger-color;
&:active {
background: hsl(0,0%,98%);
@ -658,7 +659,7 @@ pre code.hljs {
}
&.btn--danger {
color: hsl(0,65%,55%);
color: $danger-color;
&:active {
color: hsl(0,65%,40%);
@ -5092,6 +5093,7 @@ footer {
.ok-color { fill: $ok-color; }
.bad-color { fill: $bad-color; }
.superbad-color { fill: $superbad-color; }
.danger-color { color: $danger-color; }
.stat-widgets {
margin: -7px -7px 20px;
@ -7563,7 +7565,7 @@ footer {
.dropdown-menu > li.danger:hover,
.dropdown-menu > li.danger.is-active {
background: hsl(0,65%,55%);
background: $danger-color;
}
.dropdown-menu > li.create:hover,
@ -9470,7 +9472,7 @@ output {
flex-wrap: wrap;
padding: 10px;
margin-bottom: 17px;
&.is-inactive {
background: none;
box-shadow: none;
@ -9483,6 +9485,17 @@ output {
}
}
&--placeholder {
padding: 30px;
align-items: center;
justify-content: center;
color: hsl(206,9%,69%);
box-shadow: none;
background: none;
border-style: dashed;
font-style: italic;
}
&-alert {
width: calc(100% + 20px);
margin: -10px -10px 10px;

View file

@ -0,0 +1,26 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class DataPrivacyTasksController < ApplicationController
prepend_before_action { authentication_check && authorize! }
def index
model_index_render(DataPrivacyTask, params)
end
def show
model_show_render(DataPrivacyTask, params)
end
def create
model_create_render(DataPrivacyTask, params)
end
def update
model_update_render(DataPrivacyTask, params)
end
def destroy
model_destroy_render(DataPrivacyTask, params)
end
end

View file

@ -162,6 +162,11 @@ curl http://localhost/api/v1/monitoring/health_check?token=XXX
issues.push "Stuck import backend '#{backend}' detected. Last update: #{job.updated_at}"
end
# stuck data privacy tasks
DataPrivacyTask.where.not(state: 'completed').where('updated_at <= ?', 30.minutes.ago).find_each do |task|
issues.push "Stuck data privacy task (ID #{task.id}) detected. Last update: #{task.updated_at}"
end
token = Setting.get('monitoring_token')
if issues.blank?

View file

@ -0,0 +1,7 @@
class DataPrivacyTaskJob < ApplicationJob
include HasActiveJobLock
def perform
DataPrivacyTask.where(state: 'in process').find_each(&:perform)
end
end

View file

@ -1,6 +1,8 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class ApplicationModel < ActiveRecord::Base
include ActiveModel::Validations
include ApplicationModel::CanActivityStreamLog
include ApplicationModel::HasCache
include ApplicationModel::CanLookup

View file

@ -3,8 +3,13 @@ module ApplicationModel::ChecksUserColumnsFillup
extend ActiveSupport::Concern
included do
before_create :fill_up_user_create
before_update :fill_up_user_update
before_validation :fill_up_user_validate
end
def fill_up_user_validate
return fill_up_user_create if new_record?
fill_up_user_update
end
=begin

View file

@ -1,5 +1,8 @@
class Chat::Agent < ApplicationModel
belongs_to :created_by, class_name: 'User'
belongs_to :updated_by, class_name: 'User'
def seads_available
concurrent - active_chat_count
end

View file

@ -1,4 +1,5 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module ChecksClientNotification
extend ActiveSupport::Concern
@ -9,134 +10,132 @@ module ChecksClientNotification
after_destroy :notify_clients_after_destroy
end
=begin
def notify_clients_data(event)
class_name = self.class.name
class_name.gsub!(/::/, '')
notify_clients_after_create after model got created
{
message: {
event: "#{class_name}:#{event}",
data: { id: id, updated_at: updated_at }
},
type: 'authenticated',
}
end
used as callback in model file
def notify_clients_send(data)
return notify_clients_send_to(data[:message]) if client_notification_send_to.present?
class OwnModel < ApplicationModel
after_create :notify_clients_after_create
after_update :notify_clients_after_update
after_touch :notify_clients_after_touch
after_destroy :notify_clients_after_destroy
PushMessages.send(data)
end
[...]
=end
def notify_clients_send_to(data)
client_notification_send_to.each do |user_id|
PushMessages.send_to(send(user_id), data)
end
end
def notify_clients_after_create
# return if we run import mode
return if Setting.get('import_mode')
# skip if ignored
return if client_notification_events_ignored.include?(:create)
logger.debug { "#{self.class.name}.find(#{id}) notify created #{created_at}" }
class_name = self.class.name
class_name.gsub!(/::/, '')
PushMessages.send(
message: {
event: class_name + ':create',
data: { id: id, updated_at: updated_at }
},
type: 'authenticated',
)
data = notify_clients_data(:create)
notify_clients_send(data)
end
=begin
notify_clients_after_update after model got updated
used as callback in model file
class OwnModel < ApplicationModel
after_create :notify_clients_after_create
after_update :notify_clients_after_update
after_touch :notify_clients_after_touch
after_destroy :notify_clients_after_destroy
[...]
=end
def notify_clients_after_update
# return if we run import mode
return if Setting.get('import_mode')
# skip if ignored
return if client_notification_events_ignored.include?(:update)
logger.debug { "#{self.class.name}.find(#{id}) notify UPDATED #{updated_at}" }
class_name = self.class.name
class_name.gsub!(/::/, '')
PushMessages.send(
message: {
event: class_name + ':update',
data: { id: id, updated_at: updated_at }
},
type: 'authenticated',
)
data = notify_clients_data(:update)
notify_clients_send(data)
end
=begin
notify_clients_after_touch after model got touched
used as callback in model file
class OwnModel < ApplicationModel
after_create :notify_clients_after_create
after_update :notify_clients_after_update
after_touch :notify_clients_after_touch
after_destroy :notify_clients_after_destroy
[...]
=end
def notify_clients_after_touch
# return if we run import mode
return if Setting.get('import_mode')
# skip if ignored
return if client_notification_events_ignored.include?(:touch)
logger.debug { "#{self.class.name}.find(#{id}) notify TOUCH #{updated_at}" }
class_name = self.class.name
class_name.gsub!(/::/, '')
PushMessages.send(
message: {
event: class_name + ':touch',
data: { id: id, updated_at: updated_at }
},
type: 'authenticated',
)
data = notify_clients_data(:touch)
notify_clients_send(data)
end
=begin
notify_clients_after_destroy after model got destroyed
used as callback in model file
class OwnModel < ApplicationModel
after_create :notify_clients_after_create
after_update :notify_clients_after_update
after_touch :notify_clients_after_touch
after_destroy :notify_clients_after_destroy
[...]
=end
def notify_clients_after_destroy
# return if we run import mode
return if Setting.get('import_mode')
# skip if ignored
return if client_notification_events_ignored.include?(:destroy)
logger.debug { "#{self.class.name}.find(#{id}) notify DESTOY #{updated_at}" }
class_name = self.class.name
class_name.gsub!(/::/, '')
PushMessages.send(
message: {
event: class_name + ':destroy',
data: { id: id, updated_at: updated_at }
},
type: 'authenticated',
)
data = notify_clients_data(:destroy)
notify_clients_send(data)
end
private
def client_notification_events_ignored
@client_notification_events_ignored ||= self.class.instance_variable_get(:@client_notification_events_ignored) || []
end
def client_notification_send_to
@client_notification_send_to ||= self.class.instance_variable_get(:@client_notification_send_to) || []
end
# methods defined here are going to extend the class, not the instance of it
class_methods do
=begin
serve method to ignore events
class Model < ApplicationModel
include ChecksClientNotification
client_notification_events_ignored :create, :update, :touch
end
=end
def client_notification_events_ignored(*attributes)
@client_notification_events_ignored ||= []
@client_notification_events_ignored |= attributes
end
=begin
serve method to define recipient user ids
class Model < ApplicationModel
include ChecksClientNotification
client_notification_send_to :user_id
end
=end
def client_notification_send_to(*attributes)
@client_notification_send_to ||= []
@client_notification_send_to |= attributes
end
end
end

View file

@ -3,7 +3,6 @@ module HasObjectManagerAttributesValidation
extend ActiveSupport::Concern
included do
include ActiveModel::Validations
validates_with ObjectManager::Attribute::Validation, on: %i[create update]
end
end

View file

@ -0,0 +1,22 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module HasTaskbars
extend ActiveSupport::Concern
included do
before_destroy :destroy_taskbars
end
=begin
destroy all taskbars for the class object id
model = Model.find(123)
model.destroy
=end
def destroy_taskbars
Taskbar.where(key: "#{self.class}-#{id}").destroy_all
end
end

View file

@ -0,0 +1,83 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class DataPrivacyTask < ApplicationModel
include DataPrivacyTask::HasActivityStreamLog
include ChecksClientNotification
store :preferences
belongs_to :created_by, class_name: 'User'
belongs_to :updated_by, class_name: 'User'
# optional because related data will get deleted and it would
# cause validation errors if e.g. the created_by_id of the task
# would need to get mapped by a deletion
belongs_to :deletable, polymorphic: true, optional: true
before_create :prepare_deletion_preview
validates_with DataPrivacyTask::Validation
def perform
return if deletable.blank?
prepare_deletion_preview
save!
if delete_organization?
deletable.organization.destroy
else
deletable.destroy
end
update!(state: 'completed')
rescue => e
handle_exception(e)
end
def handle_exception(e)
Rails.logger.error e
preferences[:error] = "ERROR: #{e.inspect}"
self.state = 'failed'
save!
end
def delete_organization?
return false if preferences[:delete_organization].blank?
return false if preferences[:delete_organization] != 'true'
return false if !deletable.organization
return false if deletable.organization.members.count != 1
true
end
def prepare_deletion_preview
prepare_deletion_preview_tickets
prepare_deletion_preview_user
prepare_deletion_preview_organization
prepare_deletion_preview_anonymize
end
def prepare_deletion_preview_tickets
preferences[:owner_tickets] = deletable.owner_tickets.order(id: 'DESC').map(&:number)
preferences[:customer_tickets] = deletable.customer_tickets.order(id: 'DESC').map(&:number)
end
def prepare_deletion_preview_user
preferences[:user] = {
firstname: deletable.firstname,
lastname: deletable.lastname,
email: deletable.email,
}
end
def prepare_deletion_preview_organization
return if !deletable.organization
preferences[:user][:organization] = deletable.organization.name
end
def prepare_deletion_preview_anonymize
preferences[:user] = Pseudonymisation.of_hash(preferences[:user])
end
end

View file

@ -0,0 +1,18 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module DataPrivacyTask::HasActivityStreamLog
extend ActiveSupport::Concern
included do
include ::HasActivityStreamLog
after_update :log_activity
activity_stream_permission 'admin.data_privacy'
end
def log_activity
return if !saved_change_to_attribute?('state')
return if state != 'completed'
activity_stream_log('completed', created_by_id, true)
end
end

View file

@ -0,0 +1,101 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class DataPrivacyTask::Validation < ActiveModel::Validator
attr_reader :record
def validate(record)
@record = record
check_for_user
check_for_system_user
check_for_current_user
check_for_last_admin
check_for_existing_task
end
private
def check_for_user
return if deletable_is_user?
invalid_because(:deletable, 'is not a User')
end
def check_for_system_user
return if !deletable_is_user?
return if deletable.id != 1
invalid_because(:deletable, 'is undeletable system User with ID 1')
end
def check_for_current_user
return if !deletable_is_user?
return if deletable.id != UserInfo.current_user_id
invalid_because(:deletable, 'is your current account')
end
def check_for_last_admin
return if !deletable_is_user?
return if !last_admin?
invalid_because(:deletable, 'is last account with admin permissions')
end
def check_for_existing_task
return if !deletable_is_user?
return if !tasks_exists?
invalid_because(:deletable, 'has an existing DataPrivacyTask queued')
end
def deletable_is_user?
deletable.is_a?(User)
end
def deletable
record.deletable
end
def invalid_because(attribute, message)
record.errors.add attribute, message
end
def tasks_exists?
DataPrivacyTask.where(
deletable: deletable
).where.not(
id: record.id,
state: 'failed'
).exists?
end
def last_admin?
return false if !deletable_is_admin?
future_admin_ids.blank?
end
def future_admin_ids
other_admin_ids - existing_jobs_admin_ids
end
def other_admin_ids
admin_users.where.not(id: deletable.id).pluck(:id)
end
def deletable_is_admin?
admin_users.exists?(id: deletable.id)
end
def existing_jobs_admin_ids
DataPrivacyTask.where(
deletable_id: other_admin_ids,
deletable_type: 'User'
).pluck(:deletable_id)
end
def admin_users
User.with_permissions('admin')
end
end

View file

@ -2,6 +2,7 @@
class Karma::ActivityLog < ApplicationModel
belongs_to :object_lookup, optional: true
belongs_to :user, class_name: '::User'
self.table_name = 'karma_activity_logs'

View file

@ -9,12 +9,14 @@ class Organization < ApplicationModel
include CanCsvImport
include ChecksHtmlSanitized
include HasObjectManagerAttributesValidation
include HasTaskbars
include Organization::Assets
include Organization::Search
include Organization::SearchIndex
has_many :members, class_name: 'User'
has_many :members, class_name: 'User', dependent: :destroy
has_many :tickets, class_name: 'Ticket', dependent: :destroy
before_create :domain_cleanup
before_update :domain_cleanup

View file

@ -6,6 +6,7 @@ class RecentView < ApplicationModel
# rubocop:disable Rails/InverseOf
belongs_to :ticket, foreign_key: 'o_id', optional: true
belongs_to :object, class_name: 'ObjectLookup', foreign_key: 'recent_view_object_id', optional: true
belongs_to :created_by, class_name: 'User'
# rubocop:enable Rails/InverseOf
after_create :notify_clients

View file

@ -1,15 +1,24 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Taskbar < ApplicationModel
include ChecksClientNotification
store :state
store :params
store :preferences
belongs_to :user
before_create :update_last_contact, :set_user, :update_preferences_infos
before_update :update_last_contact, :set_user, :update_preferences_infos
after_update :notify_clients
after_destroy :update_preferences_infos, :notify_clients
client_notification_events_ignored :create, :update, :touch
client_notification_send_to :user_id
attr_accessor :local_update
def state_changed?

View file

@ -3,6 +3,10 @@
class Template < ApplicationModel
include ChecksClientNotification
belongs_to :user, optional: true
store :options
validates :name, presence: true
association_attributes_ignored :user
end

View file

@ -5,6 +5,8 @@ class TextModule < ApplicationModel
include ChecksHtmlSanitized
include CanCsvImport
belongs_to :user, optional: true
validates :name, presence: true
validates :content, presence: true
@ -17,6 +19,8 @@ class TextModule < ApplicationModel
has_and_belongs_to_many :groups, after_add: :cache_update, after_remove: :cache_update, class_name: 'Group'
association_attributes_ignored :user
=begin
load text modules from online

View file

@ -14,6 +14,7 @@ class Ticket < ApplicationModel
include HasKarmaActivityLog
include HasLinks
include HasObjectManagerAttributesValidation
include HasTaskbars
include Ticket::Escalation
include Ticket::Subject
@ -64,6 +65,7 @@ class Ticket < ApplicationModel
belongs_to :organization, optional: true
has_many :articles, class_name: 'Ticket::Article', after_add: :cache_update, after_remove: :cache_update, dependent: :destroy, inverse_of: :ticket
has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket
has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy
belongs_to :state, class_name: 'Ticket::State', optional: true
belongs_to :priority, class_name: 'Ticket::Priority', optional: true
belongs_to :owner, class_name: 'User', optional: true
@ -73,6 +75,8 @@ class Ticket < ApplicationModel
belongs_to :create_article_type, class_name: 'Ticket::Article::Type', optional: true
belongs_to :create_article_sender, class_name: 'Ticket::Article::Sender', optional: true
association_attributes_ignored :flags
self.inheritance_column = nil
attr_accessor :callback_loop

View file

@ -1,4 +1,7 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Ticket::Flag < ApplicationModel
belongs_to :ticket
association_attributes_ignored :ticket
end

View file

@ -14,16 +14,32 @@ class User < ApplicationModel
include HasRoles
include HasObjectManagerAttributesValidation
include HasTicketCreateScreenImpact
include HasTaskbars
include User::HasTicketCreateScreenImpact
include User::Assets
include User::Search
include User::SearchIndex
has_and_belongs_to_many :organizations, after_add: :cache_update, after_remove: :cache_update, class_name: 'Organization'
has_many :tokens, after_add: :cache_update, after_remove: :cache_update
has_many :authorizations, after_add: :cache_update, after_remove: :cache_update
belongs_to :organization, inverse_of: :members, optional: true
has_many :permissions, -> { where(roles: { active: true }, active: true) }, through: :roles
has_and_belongs_to_many :organizations, after_add: :cache_update, after_remove: :cache_update, class_name: 'Organization'
has_many :tokens, after_add: :cache_update, after_remove: :cache_update, dependent: :destroy
has_many :authorizations, after_add: :cache_update, after_remove: :cache_update, dependent: :destroy
has_many :online_notifications, dependent: :destroy
has_many :templates, dependent: :destroy
has_many :taskbars, dependent: :destroy
has_many :user_devices, dependent: :destroy
has_one :chat_agent_created_by, class_name: 'Chat::Agent', foreign_key: :created_by_id, dependent: :destroy, inverse_of: :created_by
has_one :chat_agent_updated_by, class_name: 'Chat::Agent', foreign_key: :updated_by_id, dependent: :destroy, inverse_of: :updated_by
has_many :chat_sessions, class_name: 'Chat::Session', dependent: :destroy
has_many :karma_user, class_name: 'Karma::User', dependent: :destroy
has_many :karma_activity_logs, class_name: 'Karma::ActivityLog', dependent: :destroy
has_many :cti_caller_ids, class_name: 'Cti::CallerId', dependent: :destroy
has_many :text_modules, dependent: :destroy
has_many :customer_tickets, class_name: 'Ticket', foreign_key: :customer_id, dependent: :destroy, inverse_of: :customer
has_many :owner_tickets, class_name: 'Ticket', foreign_key: :owner_id, inverse_of: :owner
has_many :created_recent_views, class_name: 'RecentView', foreign_key: :created_by_id, dependent: :destroy, inverse_of: :created_by
has_many :permissions, -> { where(roles: { active: true }, active: true) }, through: :roles
has_many :data_privacy_tasks, as: :deletable
belongs_to :organization, inverse_of: :members, optional: true
before_validation :check_name, :check_email, :check_login, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier
before_validation :check_mail_delivery_failed, on: :update
@ -32,10 +48,12 @@ class User < ApplicationModel
after_create :avatar_for_email_check, unless: -> { BulkImportInfo.enabled? }
after_update :avatar_for_email_check, unless: -> { BulkImportInfo.enabled? }
after_commit :update_caller_id
before_destroy :destroy_longer_required_objects
before_destroy :destroy_longer_required_objects, :destroy_move_dependency_ownership
store :preferences
association_attributes_ignored :online_notifications, :templates, :taskbars, :user_devices, :chat_sessions, :karma_activity_logs, :cti_caller_ids, :text_modules, :customer_tickets, :owner_tickets, :created_recent_views, :chat_agents, :data_privacy_tasks
activity_stream_permission 'admin.user'
activity_stream_attributes_ignored :last_login,
@ -1147,20 +1165,39 @@ raise 'Minimum one user need to have admin permissions'
end
def destroy_longer_required_objects
::Authorization.where(user_id: id).destroy_all
::Avatar.remove('User', id)
::Cti::CallerId.where(user_id: id).destroy_all
::Taskbar.where(user_id: id).destroy_all
::Karma::ActivityLog.where(user_id: id).destroy_all
::Karma::User.where(user_id: id).destroy_all
::OnlineNotification.where(user_id: id).destroy_all
::RecentView.where(created_by_id: id).destroy_all
::Avatar.remove(self.class.to_s, id)
::UserDevice.remove(id)
::Token.where(user_id: id).destroy_all
::StatsStore.remove(
object: 'User',
object: self.class.to_s,
o_id: id,
)
end
def destroy_move_dependency_ownership
result = Models.references(self.class.to_s, id)
result.each do |class_name, references|
next if class_name.blank?
next if references.blank?
ref_class = class_name.constantize
references.each do |column, reference_found|
next if !reference_found
if %w[created_by_id updated_by_id origin_by_id owner_id archived_by_id published_by_id internal_by_id].include?(column)
ref_class.where(column => id).find_in_batches(batch_size: 1000) do |batch_list|
batch_list.each do |record|
record.update!(column => 1)
rescue => e
Rails.logger.error e
end
end
elsif ref_class.exists?(column => id)
raise "Failed deleting references! Check logic for #{class_name}->#{column}."
end
end
end
true
end

View file

@ -5,9 +5,13 @@ class UserDevice < ApplicationModel
store :location_details
validates :name, presence: true
belongs_to :user
before_create :fingerprint_validation
before_update :fingerprint_validation
association_attributes_ignored :user
=begin
store new device for user if device not already known

View file

@ -0,0 +1,3 @@
class Controllers::DataPrivacyTasksControllerPolicy < Controllers::ApplicationControllerPolicy
default_permit!('admin.data_privacy')
end

View file

@ -0,0 +1,10 @@
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
match api_path + '/data_privacy_tasks', to: 'data_privacy_tasks#index', via: :get
match api_path + '/data_privacy_tasks/:id', to: 'data_privacy_tasks#show', via: :get
match api_path + '/data_privacy_tasks', to: 'data_privacy_tasks#create', via: :post
match api_path + '/data_privacy_tasks/:id', to: 'data_privacy_tasks#update', via: :put
match api_path + '/data_privacy_tasks/:id', to: 'data_privacy_tasks#destroy', via: :delete
end

View file

@ -740,5 +740,17 @@ class CreateBase < ActiveRecord::Migration[4.2]
add_index :smime_certificates, [:fingerprint], unique: true
add_index :smime_certificates, [:modulus]
add_index :smime_certificates, [:subject]
create_table :data_privacy_tasks do |t|
t.column :name, :string, limit: 150, null: true
t.column :state, :string, limit: 150, default: 'in process', null: true
t.references :deletable, polymorphic: true
t.string :preferences, limit: 8000, null: true
t.column :updated_by_id, :integer, null: false
t.column :created_by_id, :integer, null: false
t.timestamps limit: 3, null: false
end
add_index :data_privacy_tasks, [:name]
add_index :data_privacy_tasks, [:state]
end
end

View file

@ -0,0 +1,52 @@
class DataPrivacyInit < ActiveRecord::Migration[4.2]
def up
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
up_table
up_permission
up_scheduler
end
def up_table
create_table :data_privacy_tasks do |t|
t.column :name, :string, limit: 150, null: true
t.column :state, :string, limit: 150, default: 'in process', null: true
t.references :deletable, polymorphic: true
t.string :preferences, limit: 8000, null: true
t.column :updated_by_id, :integer, null: false
t.column :created_by_id, :integer, null: false
t.timestamps limit: 3, null: false
end
add_index :data_privacy_tasks, [:name]
add_index :data_privacy_tasks, [:state]
end
def up_permission
Permission.create_if_not_exists(
name: 'admin.data_privacy',
note: 'Manage %s',
preferences: {
translations: ['Data Privacy']
},
)
end
def up_scheduler
Scheduler.create_or_update(
name: 'Handle data privacy tasks.',
method: 'DataPrivacyTaskJob.perform_now',
period: 10.minutes,
last_run: Time.zone.now,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
end
def self.down
drop_table :data_privacy_tasks
end
end

View file

@ -227,6 +227,13 @@ Permission.create_if_not_exists(
translations: ['Monitoring']
},
)
Permission.create_if_not_exists(
name: 'admin.data_privacy',
note: 'Manage %s',
preferences: {
translations: ['Data Privacy']
},
)
Permission.create_if_not_exists(
name: 'admin.maintenance',
note: 'Manage %s',

View file

@ -199,3 +199,13 @@ Scheduler.create_if_not_exists(
updated_by_id: 1,
created_by_id: 1
)
Scheduler.create_if_not_exists(
name: 'Handle data privacy tasks.',
method: 'DataPrivacyTaskJob.perform_now',
period: 10.minutes,
last_run: Time.zone.now,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)

View file

@ -114,7 +114,7 @@ returns
=end
def self.references(object_name, object_id)
def self.references(object_name, object_id, include_zero = false)
object_name = object_name.to_s
# check if model exists
@ -143,7 +143,7 @@ returns
next if !model_attributes[:attributes].include?(item)
count = model_class.where("#{item} = ?", object_id).count
next if count.zero?
next if count.zero? && !include_zero
if !references[model_class.to_s][item]
references[model_class.to_s][item] = 0
@ -166,7 +166,7 @@ returns
if reflection_value.options[:class_name] == object_name
count = model_class.where("#{col_name} = ?", object_id).count
next if count.zero?
next if count.zero? && !include_zero
if !references[model_class.to_s][col_name]
references[model_class.to_s][col_name] = 0
@ -179,7 +179,7 @@ returns
next if reflection_value.name != object_name.downcase.to_sym
count = model_class.where("#{col_name} = ?", object_id).count
next if count.zero?
next if count.zero? && !include_zero
if !references[model_class.to_s][col_name]
references[model_class.to_s][col_name] = 0

43
lib/pseudonymisation.rb Normal file
View file

@ -0,0 +1,43 @@
class Pseudonymisation
def self.of_hash(source)
return if source.blank?
source.transform_values do |value|
of_value(value)
end
end
def self.of_value(source)
of_email_address(source)
rescue
of_string(source)
end
def self.of_email_address(source)
email_address = Mail::AddressList.new(source).addresses.first
"#{of_string(email_address.local)}@#{of_domain(email_address.domain)}"
rescue
raise ArgumentError
end
def self.of_domain(source)
domain_parts = source.split('.')
# e.g. localhost
return of_string(source) if domain_parts.size == 1
tld = domain_parts[-1]
other = domain_parts[0..-2].join('.')
"#{of_string(other)}.#{tld}"
end
def self.of_string(source)
return '*' if source.length == 1
return "#{source.first}*#{source.last}" if source.exclude?(' ')
source.split(' ').map do |sub_string|
of_string(sub_string)
end.join(' ')
end
end

View file

@ -23,7 +23,8 @@ RSpec.describe Issue1977RemoveInvalidUserForeignKeys, type: :db_migration do
without_foreign_key(:online_notifications, column: :user_id)
without_foreign_key(:recent_views, column: :created_by_id)
create(:recent_view, created_by_id: 1337)
record = build(:recent_view, created_by_id: 1337)
record.save(validate: false)
create(:recent_view, created_by_id: existing_user_id)
expect do

View file

@ -0,0 +1,6 @@
FactoryBot.define do
factory :'chat/agent' do
created_by_id { 1 }
updated_by_id { 1 }
end
end

View file

@ -0,0 +1,6 @@
FactoryBot.define do
factory :data_privacy_task do
created_by_id { 1 }
updated_by_id { 1 }
end
end

View file

@ -1,5 +1,7 @@
FactoryBot.define do
factory 'knowledge_base/answer/translation', aliases: %i[knowledge_base_answer_translation] do
created_by_id { 1 }
updated_by_id { 1 }
answer { nil }
kb_locale { nil }
sequence(:title) { |n| "#{Faker::Appliance.equipment} ##{n}" }

View file

@ -7,5 +7,6 @@ FactoryBot.define do
state {}
prio { 1 }
notify { false }
user_id { 1 }
end
end

View file

@ -0,0 +1,8 @@
FactoryBot.define do
factory :'ticket/flag', aliases: %i[ticket_flag] do
ticket
key { "key_#{rand(100)}" }
value { "value_#{rand(100)}" }
created_by_id { 1 }
end
end

View file

@ -0,0 +1,52 @@
require 'rails_helper'
RSpec.describe DataPrivacyTaskJob, type: :job do
describe '#perform' do
before do
Setting.set('system_init_done', true)
end
let!(:organization) { create(:organization, name: 'test') }
let!(:admin) { create(:admin) }
let!(:user) { create(:customer, organization: organization) }
it 'checks if the user is deleted' do
create(:data_privacy_task, deletable: user)
described_class.perform_now
expect { user.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'checks if the organization is deleted' do
create(:data_privacy_task, deletable: user)
described_class.perform_now
expect(organization.reload).to be_a_kind_of(Organization)
end
it 'checks if the state is completed' do
task = create(:data_privacy_task, deletable: user)
described_class.perform_now
expect(task.reload.state).to eq('completed')
end
it 'checks if the user is deleted (delete_organization=true)' do
create(:data_privacy_task, deletable: user, preferences: { delete_organization: 'true' })
described_class.perform_now
expect { user.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'checks if the organization is deleted (delete_organization=true)' do
create(:data_privacy_task, deletable: user, preferences: { delete_organization: 'true' })
described_class.perform_now
expect { organization.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'checks creation of activity stream log' do
create(:data_privacy_task, deletable: user, created_by: admin)
travel 15.minutes
described_class.perform_now
expect(admin.activity_stream(20).any? { |entry| entry.type.name == 'completed' }).to be true
end
end
end

View file

@ -0,0 +1,111 @@
require 'rails_helper'
RSpec.describe Pseudonymisation do
describe '.of_hash' do
let(:source) do
{
firstname: 'John',
lastname: 'Doe',
email: 'john.doe@example.com',
organization: 'Example Inc.',
}
end
let(:result) do
{
firstname: 'J*n',
lastname: 'D*e',
email: 'j*e@e*e.com',
organization: 'E*e I*.',
}
end
it 'creates pseudonymous hash' do
expect(described_class.of_hash(source)).to eq(result)
end
end
describe '.of_value' do
context 'when email address is given' do
let(:source) { 'test@example.com' }
it 'creates pseudonymous email_address' do
expect(described_class.of_value(source)).to eq('t*t@e*e.com')
end
end
context 'when string is given' do
let(:source) { 'Zammad' }
it 'creates pseudonymous string' do
expect(described_class.of_value(source)).to eq('Z*d')
end
end
end
describe '.of_email_address' do
let(:source) { 'test@example.com' }
it 'creates pseudonymous email_address' do
expect(described_class.of_email_address(source)).to eq('t*t@e*e.com')
end
context 'when address is invalid' do
it 'raises ArgumentError for parsing errors' do
expect { described_class.of_email_address('i_m_no_address@') }.to raise_exception(ArgumentError)
end
it 'raises ArgumentError for string argument' do
expect { described_class.of_email_address('i_m_no_address') }.to raise_exception(ArgumentError)
end
end
end
describe '.of_domain' do
let(:source) { 'zammad.com' }
it 'creates pseudonymous string with TLD' do
expect(described_class.of_domain(source)).to eq('z*d.com')
end
context 'when no TLD is present' do
let(:source) { 'localhost' }
it 'creates pseudonymous string' do
expect(described_class.of_domain(source)).to eq('l*t')
end
end
end
describe '.of_string' do
let(:source) { 'Zammad' }
it 'creates pseudonymous string' do
expect(described_class.of_string(source)).to eq('Z*d')
end
context 'when only one char long' do
let(:source) { 'a' }
it 'returns *' do
expect(described_class.of_string(source)).to eq('*')
end
end
context 'when multiple sub-strings are given' do
let(:source) { 'Zammad Foundation' }
it 'create pseudonymous string for each' do
expect(described_class.of_string(source)).to eq('Z*d F*n')
end
end
end
end

View file

@ -0,0 +1,11 @@
RSpec.shared_examples 'HasTaskbars' do
subject { create(described_class.name.underscore) }
describe '#destroy_taskbars' do
it 'destroys related taskbars' do
taskbar = create(:taskbar, key: "#{described_class.name}-#{subject.id}")
subject.destroy
expect { taskbar.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
end
end

View file

@ -0,0 +1,92 @@
require 'rails_helper'
RSpec.describe DataPrivacyTask, type: :model do
describe '.perform' do
let(:organization) { create(:organization, name: 'test') }
let!(:admin) { create(:admin) }
let(:user) { create(:customer, organization: organization) }
it 'blocks other objects than user objects' do
expect { create(:data_privacy_task, deletable: create(:chat)) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is not a User')
end
it 'blocks the multiple deletion tasks for the same user' do
create(:data_privacy_task, deletable: user)
expect { create(:data_privacy_task, deletable: user) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable has an existing DataPrivacyTask queued')
end
it 'blocks deletion task for user id 1' do
expect { create(:data_privacy_task, deletable: User.find(1)) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is undeletable system User with ID 1')
end
it 'blocks deletion task for yourself' do
UserInfo.current_user_id = user.id
expect { create(:data_privacy_task, deletable: user) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is your current account')
end
it 'blocks deletion task for last admin' do
expect { create(:data_privacy_task, deletable: admin) }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Deletable is last account with admin permissions')
end
it 'allows deletion task for last two admins' do
create(:admin)
admin = create(:admin)
expect(create(:data_privacy_task, deletable: admin)).to be_truthy
end
it 'sets the failed state when task failed' do
task = create(:data_privacy_task, deletable: user)
user.destroy
task.perform
expect(task.reload.state).to eq('failed')
end
it 'sets an error message when task failed' do
task = create(:data_privacy_task, deletable: user)
user.destroy
task.perform
expect(task.reload.preferences[:error]).to eq("ERROR: #<ActiveRecord::RecordNotFound: Couldn't find User with 'id'=#{user.id}>")
end
end
describe '#prepare_deletion_preview' do
let(:organization) { create(:organization, name: 'Zammad GmbH') }
let(:user) { create(:customer, organization: organization, email: 'secret@example.com') }
let(:task) { create(:data_privacy_task, deletable: user) }
context 'when storing user data' do
let(:pseudonymous_data) do
{
'firstname' => 'N*e',
'lastname' => 'B*n',
'email' => 's*t@e*e.com',
'organization' => 'Z*d G*H',
}
end
it 'creates pseudonymous representation' do
expect(task[:preferences][:user]).to eq(pseudonymous_data)
end
end
context 'when User is owner of Tickets' do
let!(:owner_tickets) { create_list(:ticket, 3, owner: user) }
it 'stores the numbers' do
expect(task[:preferences][:owner_tickets]).to eq(owner_tickets.reverse.map(&:number))
end
end
context 'when User is customer of Tickets' do
let!(:customer_tickets) { create_list(:ticket, 3, customer: user) }
it 'stores the numbers' do
expect(task[:preferences][:customer_tickets]).to eq(customer_tickets.reverse.map(&:number))
end
end
end
end

View file

@ -5,6 +5,7 @@ require 'models/concerns/has_history_examples'
require 'models/concerns/has_search_index_backend_examples'
require 'models/concerns/has_xss_sanitized_note_examples'
require 'models/concerns/has_object_manager_attributes_validation_examples'
require 'models/concerns/has_taskbars_examples'
RSpec.describe Organization, type: :model do
it_behaves_like 'ApplicationModel', can_assets: { associations: :members }
@ -13,6 +14,7 @@ RSpec.describe Organization, type: :model do
it_behaves_like 'HasSearchIndexBackend', indexed_factory: :organization
it_behaves_like 'HasXssSanitizedNote', model_factory: :organization
it_behaves_like 'HasObjectManagerAttributesValidation'
it_behaves_like 'HasTaskbars'
subject(:organization) { create(:organization) }
@ -24,6 +26,28 @@ RSpec.describe Organization, type: :model do
expect(organizations).not_to be_blank
end
end
describe '.destroy' do
let!(:refs_known) { { 'Ticket' => { 'organization_id'=> 1 }, 'User' => { 'organization_id'=> 1 } } }
let!(:user) { create(:customer, organization: organization) }
let!(:ticket) { create(:ticket, organization: organization, customer: user) }
it 'checks known refs' do
refs_organization = Models.references('Organization', organization.id, true)
expect(refs_organization).to eq(refs_known)
end
it 'checks user deletion' do
organization.destroy
expect { user.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
it 'checks ticket deletion' do
organization.destroy
expect { ticket.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
end
end
describe 'Callbacks, Observers, & Async Transactions -' do

View file

@ -116,7 +116,7 @@ RSpec.describe Taskbar do
end
end
context 'multible creation' do
context 'multiple creation' do
it 'create tasks' do
@ -132,6 +132,7 @@ RSpec.describe Taskbar do
state: {},
prio: 1,
notify: false,
user_id: 1,
)
UserInfo.current_user_id = 2
@ -145,6 +146,7 @@ RSpec.describe Taskbar do
state: {},
prio: 2,
notify: false,
user_id: 1,
)
taskbar1.reload
@ -171,6 +173,7 @@ RSpec.describe Taskbar do
state: {},
prio: 2,
notify: false,
user_id: 1,
)
taskbar1.reload
@ -205,6 +208,7 @@ RSpec.describe Taskbar do
state: {},
prio: 4,
notify: false,
user_id: 1,
)
taskbar1.reload

View file

@ -4,6 +4,7 @@ require 'models/concerns/can_be_imported_examples'
require 'models/concerns/can_csv_import_examples'
require 'models/concerns/has_history_examples'
require 'models/concerns/has_tags_examples'
require 'models/concerns/has_taskbars_examples'
require 'models/concerns/has_xss_sanitized_note_examples'
require 'models/concerns/has_object_manager_attributes_validation_examples'
@ -13,6 +14,7 @@ RSpec.describe Ticket, type: :model do
it_behaves_like 'CanCsvImport'
it_behaves_like 'HasHistory', history_relation_object: 'Ticket::Article'
it_behaves_like 'HasTags'
it_behaves_like 'HasTaskbars'
it_behaves_like 'HasXssSanitizedNote', model_factory: :ticket
it_behaves_like 'HasObjectManagerAttributesValidation'
@ -1114,6 +1116,27 @@ RSpec.describe Ticket, type: :model do
.to(false)
end
it 'destroys all related dependencies' do
refs_known = { 'Ticket::Article' => { 'ticket_id'=>1 },
'Ticket::TimeAccounting' => { 'ticket_id'=>1 },
'Ticket::Flag' => { 'ticket_id'=>1 } }
ticket = create(:ticket)
article = create(:ticket_article, ticket: ticket)
accounting = create(:ticket_time_accounting, ticket: ticket)
flag = create(:ticket_flag, ticket: ticket)
refs_ticket = Models.references('Ticket', ticket.id, true)
expect(refs_ticket).to eq(refs_known)
ticket.destroy
expect { ticket.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { article.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { accounting.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { flag.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
context 'when ticket is generated from email (with attachments)' do
subject(:ticket) { Channel::EmailParser.new.process({}, raw_email).first }

View file

@ -9,6 +9,7 @@ require 'models/concerns/can_be_imported_examples'
require 'models/concerns/has_object_manager_attributes_validation_examples'
require 'models/user/has_ticket_create_screen_impact_examples'
require 'models/user/can_lookup_search_index_attributes_examples'
require 'models/concerns/has_taskbars_examples'
RSpec.describe User, type: :model do
subject(:user) { create(:user) }
@ -27,6 +28,7 @@ RSpec.describe User, type: :model do
it_behaves_like 'HasObjectManagerAttributesValidation'
it_behaves_like 'HasTicketCreateScreenImpact'
it_behaves_like 'CanLookupSearchIndexAttributes'
it_behaves_like 'HasTaskbars'
describe 'Class methods:' do
describe '.authenticate' do
@ -812,6 +814,144 @@ RSpec.describe User, type: :model do
end
describe 'Associations:' do
subject(:user) { create(:agent, groups: [group_subject]) }
let!(:group_subject) { create(:group) }
it 'does remove references before destroy' do
refs_known = { 'Group' => { 'created_by_id' => 1, 'updated_by_id' => 0 },
'Token' => { 'user_id' => 1 },
'Ticket::Article' =>
{ 'created_by_id' => 0, 'updated_by_id' => 0, 'origin_by_id' => 1 },
'Ticket::StateType' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::Article::Sender' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::Article::Type' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::Article::Flag' => { 'created_by_id' => 0 },
'Ticket::Priority' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::TimeAccounting' => { 'created_by_id' => 0 },
'Ticket::State' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::Flag' => { 'created_by_id' => 0 },
'PostmasterFilter' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'OnlineNotification' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket' =>
{ 'created_by_id' => 0, 'updated_by_id' => 0, 'owner_id' => 1, 'customer_id' => 3 },
'Template' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 },
'Avatar' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Scheduler' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Chat' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'HttpLog' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'EmailAddress' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Taskbar' => { 'user_id' => 1 },
'Sla' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'UserDevice' => { 'user_id' => 1 },
'Chat::Message' => { 'created_by_id' => 0 },
'Chat::Agent' => { 'created_by_id' => 1, 'updated_by_id' => 1 },
'Chat::Session' => { 'user_id' => 0, 'created_by_id' => 0, 'updated_by_id' => 0 },
'Tag' => { 'created_by_id' => 0 },
'Karma::User' => { 'user_id' => 0 },
'Karma::ActivityLog' => { 'user_id' => 1 },
'RecentView' => { 'created_by_id' => 1 },
'KnowledgeBase::Answer::Translation' =>
{ 'created_by_id' => 0, 'updated_by_id' => 0 },
'KnowledgeBase::Answer' =>
{ 'archived_by_id' => 1, 'published_by_id' => 1, 'internal_by_id' => 1 },
'Report::Profile' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Package' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Job' => { 'created_by_id' => 0, 'updated_by_id' => 1 },
'Store' => { 'created_by_id' => 0 },
'Cti::CallerId' => { 'user_id' => 1 },
'DataPrivacyTask' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Trigger' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Translation' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'ObjectManager::Attribute' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'User' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Organization' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Macro' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Channel' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Role' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'History' => { 'created_by_id' => 1 },
'Overview' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'ActivityStream' => { 'created_by_id' => 0 },
'StatsStore' => { 'created_by_id' => 0 },
'TextModule' => { 'user_id' => 1, 'created_by_id' => 0, 'updated_by_id' => 0 },
'Calendar' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'UserGroup' => { 'user_id' => 1 },
'Signature' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Authorization' => { 'user_id' => 1 } }
# delete objects
token = create(:token, user: user)
online_notification = create(:online_notification, user: user)
template = create(:template, :dummy_data, user: user)
taskbar = create(:taskbar, user: user)
user_device = create(:user_device, user: user)
karma_activity_log = create(:karma_activity_log, user: user)
cti_caller_id = create(:cti_caller_id, user: user)
text_module = create(:text_module, user: user)
authorization = create(:twitter_authorization, user: user)
recent_view = create(:recent_view, created_by: user)
avatar = create(:avatar, o_id: user.id)
# create a chat agent for admin user (id=1) before agent user
# to be sure that the data gets removed and not mapped which
# would result in a foreign key because of the unique key on the
# created_by_id and updated_by_id.
create(:'chat/agent')
chat_agent_user = create(:'chat/agent', created_by_id: user.id, updated_by_id: user.id)
# move ownership objects
group = create(:group, created_by_id: user.id)
job = create(:job, updated_by_id: user.id)
ticket = create(:ticket, group: group_subject, owner: user)
ticket_article = create(:ticket_article, ticket: ticket, origin_by_id: user.id)
customer_ticket1 = create(:ticket, group: group_subject, customer: user)
customer_ticket2 = create(:ticket, group: group_subject, customer: user)
customer_ticket3 = create(:ticket, group: group_subject, customer: user)
knowledge_base_answer = create(:knowledge_base_answer, archived_by_id: user.id, published_by_id: user.id, internal_by_id: user.id)
refs_user = Models.references('User', user.id, true)
expect(refs_user).to eq(refs_known)
user.destroy
expect { token.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { online_notification.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { template.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { taskbar.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { user_device.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { karma_activity_log.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { cti_caller_id.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { text_module.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { authorization.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { recent_view.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { avatar.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { customer_ticket1.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { customer_ticket2.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { customer_ticket3.reload }.to raise_exception(ActiveRecord::RecordNotFound)
expect { chat_agent_user.reload }.to raise_exception(ActiveRecord::RecordNotFound)
# move ownership objects
expect { group.reload }.to change(group, :created_by_id).to(1)
expect { job.reload }.to change(job, :updated_by_id).to(1)
expect { ticket.reload }.to change(ticket, :owner_id).to(1)
expect { ticket_article.reload }.to change(ticket_article, :origin_by_id).to(1)
expect { knowledge_base_answer.reload }
.to change(knowledge_base_answer, :archived_by_id).to(1)
.and change(knowledge_base_answer, :published_by_id).to(1)
.and change(knowledge_base_answer, :internal_by_id).to(1)
end
it 'does delete cache after user deletion' do
online_notification = create(:online_notification, created_by_id: user.id)
online_notification.attributes_with_association_ids
user.destroy
expect(online_notification.reload.attributes_with_association_ids['created_by_id']).to eq(1)
end
it 'does return an exception on blocking dependencies' do
expect { user.send(:destroy_move_dependency_ownership) }.to raise_error(RuntimeError, 'Failed deleting references! Check logic for UserGroup->user_id.')
end
describe '#organization' do
describe 'email domain-based assignment' do
subject(:user) { build(:user) }

View file

@ -509,6 +509,20 @@ RSpec.describe 'Monitoring', type: :request do
expect(json_response['healthy']).to eq(false)
expect(json_response['message']).to eq("Channel: Email::Notification out ;unprocessable mails: 1;Failed to run import backend 'Import::Ldap'. Cause: Some bad error;Stuck import backend 'Import::Ldap' detected. Last update: #{stuck_updated_at_timestamp}")
privacy_stuck_updated_at_timestamp = 30.minutes.ago
task = create(:data_privacy_task, deletable: customer)
task.update(updated_at: privacy_stuck_updated_at_timestamp)
# health_check
get "/api/v1/monitoring/health_check?token=#{token}", params: {}, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to be_a_kind_of(Hash)
expect(json_response['message']).to be_truthy
expect(json_response['issues']).to be_truthy
expect(json_response['healthy']).to eq(false)
expect(json_response['message']).to eq("Channel: Email::Notification out ;unprocessable mails: 1;Failed to run import backend 'Import::Ldap'. Cause: Some bad error;Stuck import backend 'Import::Ldap' detected. Last update: #{stuck_updated_at_timestamp};Stuck data privacy task (ID #{task.id}) detected. Last update: #{privacy_stuck_updated_at_timestamp}")
Setting.set('ldap_integration', false)
end

View file

@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe 'Data Privacy', type: :system, searchindex: true, authenticated_as: :authenticate do
before do
configure_elasticsearch(rebuild: true)
end
let(:customer) { create(:customer, firstname: 'Frank1') }
let(:ticket) { create(:ticket, customer: customer, group: Group.find_by(name: 'Users')) }
def authenticate
customer
ticket
true
end
context 'when data privacy admin interface' do
it 'deletes customer' do
visit 'system/data_privacy'
click '.js-new'
find(:css, '.js-input').send_keys(customer.firstname)
expect(page).to have_css('.searchableSelect-option-text', wait: 5)
click '.searchableSelect-option-text'
fill_in 'Are you sure?', with: 'DELETE'
expect(page).to have_no_text('DELETE ORGANIZATION?', wait: 5)
click '.js-submit'
expect(page).to have_text('in process', wait: 5)
DataPrivacyTaskJob.perform_now
expect(page).to have_text('completed', wait: 5)
end
context 'when customer is the single user of the organization' do
let(:organization) { create(:organization) }
let(:customer) { create(:customer, firstname: 'Frank2', organization: organization) }
def authenticate
organization
customer
ticket
true
end
it 'deletes customer' do
visit 'system/data_privacy'
click '.js-new'
find(:css, '.js-input').send_keys(customer.firstname)
expect(page).to have_css('.searchableSelect-option-text', wait: 5)
click '.searchableSelect-option-text'
fill_in 'Are you sure?', with: 'DELETE'
expect(page).to have_text('DELETE ORGANIZATION?', wait: 5)
click '.js-submit'
expect(page).to have_text('in process', wait: 5)
DataPrivacyTaskJob.perform_now
expect(page).to have_text('completed', wait: 5)
end
end
end
context 'when user profile' do
it 'deletes customer' do
visit "user/profile/#{customer.id}"
click '.dropdown--actions'
click_on 'Delete'
fill_in 'Are you sure?', with: 'DELETE'
click '.js-submit'
expect(page).to have_text('in process', wait: 5)
DataPrivacyTaskJob.perform_now
expect(page).to have_text('completed', wait: 5)
end
end
context 'when ticket zoom' do
it 'deletes customer' do
visit "ticket/zoom/#{ticket.id}"
click '.tabsSidebar-tab[data-tab=customer]'
click 'h2.js-headline'
click_on 'Delete Customer'
fill_in 'Are you sure?', with: 'DELETE'
click '.js-submit'
expect(page).to have_text('in process', wait: 5)
DataPrivacyTaskJob.perform_now
expect(page).to have_text('completed', wait: 5)
end
end
end

View file

@ -22,6 +22,9 @@ class SwitchToUserTest < TestCase
@browser.action.move_to(@browser.find_elements({ css: '.content.active .table-overview tbody tr:first-child' } )[0]).release.perform
sleep 0.5
click(
css: '.content.active .dropdown--actions',
)
click(
css: '.content.active .icon-switchView',
)