lock exported timesheets (#798)

This commit is contained in:
Kevin Papst
2019-05-22 22:28:29 +02:00
committed by GitHub
parent 871b2e52c7
commit fea3495098
22 changed files with 387 additions and 68 deletions

View File

@@ -35,6 +35,6 @@ filter:
- 'vendor/' - 'vendor/'
build_failure_conditions: build_failure_conditions:
- 'project.metric("scrutinizer.quality", < 9.30)' - 'project.metric("scrutinizer.quality", < 9.0)'
- 'project.metric("scrutinizer.test_coverage", < 0.9)' - 'project.metric("scrutinizer.test_coverage", < 0.9)'
- 'project.metric_change("scrutinizer.test_coverage", < -0.01)' - 'project.metric_change("scrutinizer.test_coverage", < -0.01)'

View File

@@ -22,11 +22,12 @@ And make sure to **create a backup before you start**.
Follow the normal update and database migration process (see above). Follow the normal update and database migration process (see above).
### Apply necessary changes to your `local.yaml`: ### Apply necessary changes to your local.yaml:
New permissions are available: New permissions are available. If you configured custom permissions in `local.yaml`, you have to add those, otherwise you can't use the new features:
- `view_tag` - view all tags - `view_tag` - view all tags
- `delete_tag` - delete tags - `delete_tag` - delete tags
- `edit_exported_timesheet` - allows to edit records which were exported
### BC BREAKS ### BC BREAKS

View File

@@ -52,7 +52,10 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
// the modal that we use to render the form in // the modal that we use to render the form in
let formIdentifier = '#remote_form_modal .modal-content form'; let formIdentifier = '#remote_form_modal .modal-content form';
// if any of these is found in a response, the form will be re-displayed
let flashErrorIdentifier = 'div.alert-error'; let flashErrorIdentifier = 'div.alert-error';
// messages to show above the form
let flashMessageIdentifier = 'div.alert';
let form = jQuery(formIdentifier); let form = jQuery(formIdentifier);
let remoteModal = jQuery('#remote_form_modal'); let remoteModal = jQuery('#remote_form_modal');
@@ -88,10 +91,9 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
} }
// show error flash messages // show error flash messages
if (jQuery(html).find(flashErrorIdentifier).length > 0) { let flashMessages = jQuery(html).find(flashMessageIdentifier);
jQuery('#remote_form_modal .modal-body').prepend( if (flashMessages.length > 0) {
jQuery(html).find(flashErrorIdentifier) jQuery('#remote_form_modal .modal-body').prepend(flashMessages);
);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -101,8 +101,8 @@ kimai:
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"]) # adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
ROLE_USER: [] ROLE_USER: []
ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile] ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile]
ROLE_ADMIN: [hourly-rate_own_profile] ROLE_ADMIN: [hourly-rate_own_profile,edit_exported_timesheet]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile,system_information,system_actions,system_configuration,plugins] ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile,system_information,system_actions,system_configuration,plugins,edit_exported_timesheet]
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{ {
"build/app.js": "./app.js?dad0cc8443da0d5789c7", "build/app.js": "./app.js?376d50667e3784222761",
"build/app.css": "./app.css?954c34ace3717cfb9d9c80d2be5f8c68", "build/app.css": "./app.css?954c34ace3717cfb9d9c80d2be5f8c68",
"build/fonts/fa-solid-900.woff2": "./fonts/fa-solid-900.woff2?e8a92a29", "build/fonts/fa-solid-900.woff2": "./fonts/fa-solid-900.woff2?e8a92a29",
"build/images/fa-solid-900.svg": "./images/fa-solid-900.svg?666a82cb", "build/images/fa-solid-900.svg": "./images/fa-solid-900.svg?666a82cb",

View File

@@ -616,4 +616,52 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
/**
* Switch the export state of a timesheet record to (un-)lock it
*
* @SWG\Response(
* response=200,
* description="Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.",
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Timesheet record ID to switch export state",
* required=true,
* )
*
* @Security("is_granted('edit_export_own_timesheet') or is_granted('edit_export_other_timesheet')")
*
* @param int $id
* @return Response
*/
public function exportAction($id)
{
/** @var Timesheet $timesheet */
$timesheet = $this->repository->find($id);
if (null === $timesheet) {
throw new NotFoundException();
}
if (!$this->isGranted('edit_export', $timesheet)) {
throw new AccessDeniedHttpException(
sprintf('You are not allowed to %s this timesheet', ($timesheet->isExported() ? 'unlock' : 'lock'))
);
}
$timesheet->setExported(!$timesheet->isExported());
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
} }

View File

@@ -18,6 +18,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/** /**
* Command used to create a release package with pre-installed composer, SQLite database and user. * Command used to create a release package with pre-installed composer, SQLite database and user.
*
* @codeCoverageIgnore
*/ */
class CreateReleaseCommand extends Command class CreateReleaseCommand extends Command
{ {

View File

@@ -37,6 +37,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
* Command used to import data from a Kimai v1 installation. * Command used to import data from a Kimai v1 installation.
* Getting help in improving this script would be fantastic, it currently only handles the most basic use-cases. * Getting help in improving this script would be fantastic, it currently only handles the most basic use-cases.
* *
* This command is way to messy and complex to be tested ... so we use something, which I actually don't like:
* @codeCoverageIgnore * @codeCoverageIgnore
*/ */
class KimaiImporterCommand extends Command class KimaiImporterCommand extends Command

View File

@@ -19,6 +19,11 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/** /**
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed. * Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
*
* This command is NOT used during runtime and only meant for developers on their local machines.
* I am too lazy to think about how this could be tested ... and this is one of the rare edge cases where I don't
* feel like it is necessary, so I "cheat" with:
* @codeCoverageIgnore
*/ */
class ResetCommand extends Command class ResetCommand extends Command
{ {

View File

@@ -93,6 +93,7 @@ class Extensions extends AbstractExtension
'debug' => 'far fa-file-alt', 'debug' => 'far fa-file-alt',
'profile-stats' => 'far fa-chart-bar', 'profile-stats' => 'far fa-chart-bar',
'profile' => 'fas fa-user-edit', 'profile' => 'fas fa-user-edit',
'warning' => 'fas fa-exclamation-triangle',
]; ];
/** /**

View File

@@ -85,12 +85,24 @@ class TimesheetVoter extends AbstractVoter
$permission .= $attribute; $permission .= $attribute;
break; break;
case self::EDIT:
if (!$this->canEdit($user, $subject)) {
return false;
}
$permission .= $attribute;
break;
case self::DELETE:
if (!$this->canDelete($user, $subject)) {
return false;
}
$permission .= $attribute;
break;
case self::VIEW_RATE: case self::VIEW_RATE:
case self::EDIT_RATE: case self::EDIT_RATE:
case self::STOP: case self::STOP:
case self::EDIT:
case self::VIEW: case self::VIEW:
case self::DELETE:
case self::EXPORT: case self::EXPORT:
case self::EDIT_EXPORT: case self::EDIT_EXPORT:
$permission .= $attribute; $permission .= $attribute;
@@ -114,11 +126,7 @@ class TimesheetVoter extends AbstractVoter
return $this->hasRolePermission($user, $permission); return $this->hasRolePermission($user, $permission);
} }
/** protected function canStart(Timesheet $timesheet): bool
* @param Timesheet $timesheet
* @return bool
*/
protected function canStart(Timesheet $timesheet)
{ {
// possible improvements for the future: // possible improvements for the future:
// we could check the amount of active entries (maybe slow) // we could check the amount of active entries (maybe slow)
@@ -142,4 +150,22 @@ class TimesheetVoter extends AbstractVoter
return true; return true;
} }
protected function canEdit(User $user, Timesheet $timesheet): bool
{
if ($timesheet->isExported() && !$this->hasRolePermission($user, 'edit_exported_timesheet')) {
return false;
}
return true;
}
protected function canDelete(User $user, Timesheet $timesheet): bool
{
if ($timesheet->isExported() && !$this->hasRolePermission($user, 'edit_exported_timesheet')) {
return false;
}
return true;
}
} }

View File

@@ -119,42 +119,56 @@
{{ parent() }} {{ parent() }}
<script type="text/javascript"> <script type="text/javascript">
function confirmToggleState() function confirmToggleState(button)
{ {
if ($('#export-toggle-button').hasClass('export-off')) { var ALERT = kimai.getPlugin('alert');
if (confirm('{{ 'export.clear_all'|trans }}')) { var message = '{{ 'export.clear_all'|trans }}';
$('.exportBtn').each(function () { var hasActive = true;
if ($(this).hasClass('active')) {
if (!$('#export-toggle-button').hasClass('export-off')) {
message = '{{ 'export.mark_all'|trans }}';
hasActive = false;
}
ALERT.question(message, function(value) {
var btn = $(button);
var exportButtons = $('.exportBtn');
// disabling does not yet work...
if (exportButtons.length > 0) {
btn.addClass('disabled');
}
// ... as the clicks are asynchronous and the each comes back too early ...
exportButtons.each(function () {
if (hasActive === $(this).hasClass('active')) {
$(this).click(); $(this).click();
} }
}); });
return true;
} // ... so the button is re-enabled immediately - that should be fixed in a future update
btn.removeClass('disabled');
if (btn.hasClass('export-off')) {
btn.removeClass('export-off');
btn.html('<i class="fas fa-toggle-off"></i>');
} else { } else {
if (confirm('{{ 'export.mark_all'|trans }}')) { btn.addClass('export-off');
$('.exportBtn').each(function () { btn.html('<i class="fas fa-toggle-on"></i>');
if (!$(this).hasClass('active')) {
$(this).click();
} }
}); });
return true;
}
}
return false;
} }
function updateTimesheetExportState(button, id, exported) function updateTimesheetExportState(button, id, exported)
{ {
// FIXME use kimai API and ALERT var ALERT = kimai.getPlugin('alert');
// FIXME use Kimai API plugin
$.ajax({ $.ajax({
url: '{{ path('patch_timesheet', {id: '-s-'}) }}'.replace('-s-', id), url: '{{ path('export_timesheet', {id: '-s-'}) }}'.replace('-s-', id),
headers: { headers: {
'X-AUTH-SESSION': true, 'X-AUTH-SESSION': true,
'Content-Type':'application/json' 'Content-Type':'application/json'
}, },
method: 'PATCH', method: 'PATCH',
dataType: 'json',
data: JSON.stringify({'exported': exported}),
success: function(data) { success: function(data) {
if (exported) { if (exported) {
button.button('exported'); button.button('exported');
@@ -181,13 +195,12 @@
} }
} }
} }
alert('{{ 'action.update.error'|trans({}, 'flashmessages') }}'.replace('%reason%', message)); ALERT.error('{{ 'action.update.error'|trans({}, 'flashmessages') }}', message);
button.button('reset'); button.button('reset');
if (exported) { if (exported) {
button.removeClass('active'); button.removeClass('active');
} else { } else {
button.addClass('active'); button.addClass('active');
} }
} }
}); });
@@ -197,25 +210,11 @@
$('body').on('click', '.exportBtn', function() { $('body').on('click', '.exportBtn', function() {
var button = $(this); var button = $(this);
var id = button.attr('data-timesheet'); var id = button.attr('data-timesheet');
updateTimesheetExportState(button, id, !button.hasClass('active'));
if (button.hasClass('active')) {
updateTimesheetExportState(button, id, false);
} else {
updateTimesheetExportState(button, id, true);
}
}); });
$('#export-toggle-button').on('click', function () { $('#export-toggle-button').on('click', function () {
if (!confirmToggleState()) { confirmToggleState(this);
return;
}
if ($(this).hasClass('export-off')) {
$(this).removeClass('export-off');
$(this).html('<i class="fas fa-toggle-off"></i>');
} else {
$(this).addClass('export-off');
$(this).html('<i class="fas fa-toggle-on"></i>');
}
}); });
$('body').on('click', '#export-buttons .startExportBtn', function() { $('body').on('click', '#export-buttons .startExportBtn', function() {

View File

@@ -116,8 +116,14 @@
{% macro alert(type, description, title, icon) %} {% macro alert(type, description, title, icon) %}
<div class="alert alert-{{ type|default('danger') }} alert-dismissible"> <div class="alert alert-{{ type|default('danger') }} alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button> <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
{% if title %}<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ title|trans }}</h4>{% endif %} {% if title and icon %}
<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ title|trans }}</h4>
{{ description|trans }} {{ description|trans }}
{% elseif icon %}
<h4><i class="icon {{ icon|icon(icon) }}"></i> {{ description|trans }}</h4>
{% else %}
{{ description|trans }}
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -7,6 +7,9 @@
{% block page_actions %}{{ actions.timesheet_team(timesheet, 'edit') }}{% endblock %} {% block page_actions %}{{ actions.timesheet_team(timesheet, 'edit') }}{% endblock %}
{% block main %} {% block main %}
{% if timesheet.exported %}
{{ widgets.alert('warning', ('timesheet.locked.warning'|trans({}, 'flashmessages')), ('warning'|trans({}, 'flashmessages')), 'warning') }}
{% endif %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', { {{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans), 'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans),
'form': form, 'form': form,

View File

@@ -7,6 +7,9 @@
{% block page_actions %}{{ actions.timesheet(timesheet, 'edit') }}{% endblock %} {% block page_actions %}{{ actions.timesheet(timesheet, 'edit') }}{% endblock %}
{% block main %} {% block main %}
{% if timesheet.exported %}
{{ widgets.alert('warning', ('timesheet.locked.warning'|trans({}, 'flashmessages')), ('warning'|trans({}, 'flashmessages')), 'warning') }}
{% endif %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', { {{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans), 'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans),
'form': form, 'form': form,

View File

@@ -427,6 +427,8 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiCallValidationError($response, ['end', 'activity']); $this->assertApiCallValidationError($response, ['end', 'activity']);
} }
// TODO: TEST PATCH FOR EXPORTED TIMESHEET FOR USER WITHOUT PERMISSION IS REJECTED
public function testDeleteAction() public function testDeleteAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -478,6 +480,36 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertEquals('You are not allowed to delete this timesheet', $json['message']); $this->assertEquals('You are not allowed to delete this timesheet', $json['message']);
} }
public function testDeleteActionForExportedRecordIsNotAllowed()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$timesheet->setExported(true);
$em->persist($timesheet);
$em->flush($timesheet);
$this->request($client, '/api/timesheets/1', 'DELETE');
$this->assertApiResponseAccessDenied($client->getResponse(), 'You are not allowed to delete this timesheet');
}
public function testDeleteActionForExportedRecordIsAllowedForAdmin()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$timesheet->setExported(true);
$em->persist($timesheet);
$em->flush($timesheet);
$this->request($client, '/api/timesheets/1', 'DELETE');
$this->assertTrue($client->getResponse()->isSuccessful());
}
public function testGetRecentCollectionWithSubresources() public function testGetRecentCollectionWithSubresources()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
@@ -731,6 +763,50 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($client->getResponse(), 'You are not allowed to re-start this timesheet'); $this->assertApiResponseAccessDenied($client->getResponse(), 'You are not allowed to re-start this timesheet');
} }
public function testRestartThrowsNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/42/restart', 'PATCH');
}
public function testExportAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$this->assertEquals(false, $timesheet->isExported());
$this->request($client, '/api/timesheets/1/export', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertDefaultStructure(json_decode($client->getResponse()->getContent(), true), true);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$this->assertEquals(true, $timesheet->isExported());
$this->request($client, '/api/timesheets/1/export', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful());
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$this->assertEquals(false, $timesheet->isExported());
}
public function testExportNotAllowedForUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/api/timesheets/1/export', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.');
}
public function testExportThrowsNotFound()
{
$this->assertEntityNotFound(User::ROLE_ADMIN, '/api/timesheets/42/export', 'PATCH');
}
protected function assertDefaultStructure(array $result, $full = true) protected function assertDefaultStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\EventSubscriber;
use App\Doctrine\SqliteSessionInitSubscriber;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Event\ConnectionEventArgs;
use Doctrine\DBAL\Events;
use Doctrine\DBAL\Platforms\MySqlPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Doctrine\SqliteSessionInitSubscriber
*/
class SqliteSessionInitSubscriberTest extends TestCase
{
public function testGetSubscribedEvents()
{
$sut = new SqliteSessionInitSubscriber();
$events = $sut->getSubscribedEvents();
$this->assertTrue(in_array(Events::postConnect, $events));
}
public function testPostConnectWithSqlite()
{
$sut = new SqliteSessionInitSubscriber();
$platformMock = $this->getMockBuilder(SqlitePlatform::class)
->setMethods(['getName'])
->disableOriginalConstructor()
->getMock();
$platformMock->expects($this->once())->method('getName')->willReturn('sqlite');
$connectionMock = $this->getMockBuilder(Connection::class)
->setMethods(['getDatabasePlatform', 'getConnection', 'executeUpdate'])
->disableOriginalConstructor()
->getMock();
$connectionMock->expects($this->once())->method('getDatabasePlatform')->willReturn($platformMock);
$connectionMock->expects($this->once())->method('executeUpdate')->with('PRAGMA foreign_keys = ON;', [], []);
$args = new ConnectionEventArgs($connectionMock);
$sut->postConnect($args);
}
public function testPostConnectWithMysql()
{
$sut = new SqliteSessionInitSubscriber();
$platformMock = $this->getMockBuilder(MySqlPlatform::class)
->setMethods(['getName'])
->disableOriginalConstructor()
->getMock();
$platformMock->expects($this->once())->method('getName')->willReturn('mysql');
$connectionMock = $this->getMockBuilder(Connection::class)
->setMethods(['getDatabasePlatform', 'getConnection', 'executeUpdate'])
->disableOriginalConstructor()
->getMock();
$connectionMock->expects($this->once())->method('getDatabasePlatform')->willReturn($platformMock);
$connectionMock->expects($this->never())->method('executeUpdate')->with('PRAGMA foreign_keys = ON;', [], []);
$args = new ConnectionEventArgs($connectionMock);
$sut->postConnect($args);
}
}

View File

@@ -79,8 +79,8 @@ abstract class AbstractVoterTest extends TestCase
$roleUser = []; $roleUser = [];
$roleTeamlead = ['view_rate_own_timesheet', 'view_rate_other_timesheet', 'hourly-rate_own_profile']; $roleTeamlead = ['view_rate_own_timesheet', 'view_rate_other_timesheet', 'hourly-rate_own_profile'];
$roleAdmin = ['hourly-rate_own_profile']; $roleAdmin = ['hourly-rate_own_profile', 'edit_exported_timesheet'];
$roleSuperAdmin = ['hourly-rate_own_profile', 'hourly-rate_other_profile', 'delete_own_profile', 'roles_own_profile']; $roleSuperAdmin = ['hourly-rate_own_profile', 'hourly-rate_other_profile', 'delete_own_profile', 'roles_own_profile', 'system_information', 'system_actions', 'system_configuration', 'plugins', 'edit_exported_timesheet'];
$permissions = [ $permissions = [
'ROLE_USER' => array_merge($timesheet, $profile, $roleUser), 'ROLE_USER' => array_merge($timesheet, $profile, $roleUser),

View File

@@ -23,10 +23,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
*/ */
class TimesheetVoterTest extends AbstractVoterTest class TimesheetVoterTest extends AbstractVoterTest
{ {
/** protected function assertVote(User $user, $subject, $attribute, $result)
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{ {
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles()); $token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles());
$sut = $this->getVoter(TimesheetVoter::class, $user); $sut = $this->getVoter(TimesheetVoter::class, $user);
@@ -34,6 +31,14 @@ class TimesheetVoterTest extends AbstractVoterTest
$this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); $this->assertEquals($result, $sut->vote($token, $subject, [$attribute]));
} }
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$this->assertVote($user, $subject, $attribute, $result);
}
public function getTestData() public function getTestData()
{ {
$user0 = $this->getUser(0, null); $user0 = $this->getUser(0, null);
@@ -46,6 +51,10 @@ class TimesheetVoterTest extends AbstractVoterTest
$timesheet2 = $this->getTimesheet($user2); $timesheet2 = $this->getTimesheet($user2);
$timesheet3 = $this->getTimesheet($user3); $timesheet3 = $this->getTimesheet($user3);
$timesheet4 = $this->getTimesheet($user4); $timesheet4 = $this->getTimesheet($user4);
$timesheet5 = $this->getTimesheet($user2);
$timesheet5->setExported(true);
$timesheet6 = $this->getTimesheet($user1);
$timesheet6->getActivity()->setVisible(false);
$result = VoterInterface::ACCESS_GRANTED; $result = VoterInterface::ACCESS_GRANTED;
$times = [ $times = [
@@ -78,6 +87,50 @@ class TimesheetVoterTest extends AbstractVoterTest
} }
} }
public function testSpecialCases()
{
$user1 = $this->getUser(1, User::ROLE_USER);
$user2 = $this->getUser(2, User::ROLE_TEAMLEAD);
$user3 = $this->getUser(3, User::ROLE_ADMIN);
$user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN);
// unknown attribute
$timesheet = $this->getTimesheet($user3);
$this->assertVote($user3, $timesheet, 'edit2', VoterInterface::ACCESS_ABSTAIN);
$timesheet = $this->getTimesheet($user2);
$timesheet->setExported(true);
// edit exported timesheet disallowed for teamleads
$this->assertVote($user2, $timesheet, 'edit', VoterInterface::ACCESS_DENIED);
$this->assertVote($user2, $timesheet, 'delete', VoterInterface::ACCESS_DENIED);
// but allowed for admins
$this->assertVote($user4, $timesheet, 'edit', VoterInterface::ACCESS_GRANTED);
$this->assertVote($user4, $timesheet, 'delete', VoterInterface::ACCESS_GRANTED);
// hidden activities might not be started
$timesheet = $this->getTimesheet($user1);
$timesheet->getActivity()->setVisible(false);
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// hidden projects might not be started
$timesheet = $this->getTimesheet($user1);
$timesheet->getProject()->setVisible(false);
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// hidden customers might not be started
$timesheet = $this->getTimesheet($user1);
$timesheet->getProject()->getCustomer()->setVisible(false);
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// cannot start timesheet without activity
$timesheet = new Timesheet();
$timesheet->setUser($user2)->setProject(new Project());
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// cannot start timesheet without project
$timesheet = new Timesheet();
$timesheet->setUser($user2)->setActivity(new Activity());
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
}
protected function getTimesheet($user) protected function getTimesheet($user)
{ {
$timesheet = new Timesheet(); $timesheet = new Timesheet();

View File

@@ -2,6 +2,10 @@
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="de" datatype="plaintext" original="flashmessages.en.xliff"> <file source-language="en" target-language="de" datatype="plaintext" original="flashmessages.en.xliff">
<body> <body>
<trans-unit id="warning">
<source>warning</source>
<target>Warnung</target>
</trans-unit>
<trans-unit id="timesheet.stop.success"> <trans-unit id="timesheet.stop.success">
<source>timesheet.stop.success</source> <source>timesheet.stop.success</source>
<target>Zeitmessung wurde gestoppt</target> <target>Zeitmessung wurde gestoppt</target>
@@ -22,6 +26,10 @@
<source>timesheet.start.exceeded_limit</source> <source>timesheet.start.exceeded_limit</source>
<target>Das Limit aktiver Zeitmessungen wurde erreicht, bitte stoppen Sie mindestens eine laufende Zeitmessung.</target> <target>Das Limit aktiver Zeitmessungen wurde erreicht, bitte stoppen Sie mindestens eine laufende Zeitmessung.</target>
</trans-unit> </trans-unit>
<trans-unit id="timesheet.locked.warning">
<source>timesheet.locked.warning</source>
<target>Sie bearbeiten einen exportierten Eintrag</target>
</trans-unit>
<trans-unit id="action.update.success"> <trans-unit id="action.update.success">
<source>action.update.success</source> <source>action.update.success</source>
<target>Änderungen gespeichert</target> <target>Änderungen gespeichert</target>

View File

@@ -2,6 +2,10 @@
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="flashmessages.en.xliff"> <file source-language="en" target-language="en" datatype="plaintext" original="flashmessages.en.xliff">
<body> <body>
<trans-unit id="warning">
<source>warning</source>
<target>Warning</target>
</trans-unit>
<trans-unit id="timesheet.stop.success"> <trans-unit id="timesheet.stop.success">
<source>timesheet.stop.success</source> <source>timesheet.stop.success</source>
<target>Time recording was stopped</target> <target>Time recording was stopped</target>
@@ -22,6 +26,10 @@
<source>timesheet.start.exceeded_limit</source> <source>timesheet.start.exceeded_limit</source>
<target>The limit of active time records has been reached, please stop at least one running time measurement first.</target> <target>The limit of active time records has been reached, please stop at least one running time measurement first.</target>
</trans-unit> </trans-unit>
<trans-unit id="timesheet.locked.warning">
<source>timesheet.locked.warning</source>
<target>You are editing an exported record</target>
</trans-unit>
<trans-unit id="action.update.success"> <trans-unit id="action.update.success">
<source>action.update.success</source> <source>action.update.success</source>
<target>Saved changes</target> <target>Saved changes</target>