Improve create and start permission handling (#613)

This commit is contained in:
Kevin Papst
2019-03-06 10:24:27 +01:00
committed by GitHub
parent bce85b04d6
commit e260dd84ad
31 changed files with 258 additions and 86 deletions

View File

@@ -272,7 +272,11 @@ $(function() {
data: $form.serialize(),
success: function(html) {
btn.button('reset');
if ($(html).find('#form_modal .modal-content .has-error').length > 0 || $(html).find(flashErrorIdentifier).length > 0) {
var hasFieldError = $(html).find('#form_modal .modal-content .has-error').length > 0;
var hasFormError = $(html).find('#form_modal .modal-content ul.list-unstyled li.text-danger').length > 0;
var hasFlashError = $(html).find(flashErrorIdentifier).length > 0;
if (hasFieldError || hasFormError || hasFlashError) {
$.kimai.ajaxFormInModal(html);
} else {
$.kimai.reloadDatatableWithToolbarFilter();

View File

@@ -114,6 +114,7 @@ services:
App\Validator\Constraints\TimesheetValidator:
arguments:
$ruleset: "%kimai.timesheet.rules%"
$durationOnly: "%kimai.timesheet.duration_only%"
# ================================================================================
# THEME

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"build/app.js": "/build/app.js?22f3accd3baabc16310c",
"build/app.css": "/build/app.css?d76b5fe68db00a63c65d6dbde19c6342",
"build/app.js": "/build/app.js?039b40a3efd42cd2846a",
"build/app.css": "/build/app.css?82e5f119685e8c17fbafec374d7a01e0",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",

View File

@@ -211,17 +211,6 @@ class TimesheetController extends BaseApiController
return new Response('You are not allowed to start this timesheet record', Response::HTTP_BAD_REQUEST);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
if (null === $timesheet->getEnd()) {
$this->repository->stopActiveEntries(
$timesheet->getUser(),
@@ -289,17 +278,6 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();

View File

@@ -97,18 +97,6 @@ trait TimesheetControllerTrait
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
if ($editForm->has('duration')) {
/** @var Timesheet $record */
$record = $editForm->getData();
$duration = $editForm->get('duration')->getData();
$end = null;
if ($duration > 0) {
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
}
$record->setEnd($end);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
@@ -174,17 +162,6 @@ trait TimesheetControllerTrait
$createForm->handleRequest($request);
if ($createForm->isSubmitted() && $createForm->isValid()) {
if ($createForm->has('duration')) {
$duration = $createForm->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $createForm->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$entityManager = $this->getDoctrine()->getManager();
try {

View File

@@ -90,8 +90,10 @@ class Configuration implements ConfigurationInterface
$class = 'App\\Timesheet\\Rounding\\' . ucfirst($value) . 'Rounding';
if (class_exists($class)) {
$rounding = new $class();
return !($rounding instanceof RoundingInterface);
}
return false;
})
->thenInvalid('Chosen rounding mode is invalid')

View File

@@ -117,7 +117,38 @@ class TimesheetEditForm extends AbstractType
if ($options['duration_only']) {
$builder->add('duration', DurationType::class, [
'required' => false,
'docu_chapter' => 'timesheet.html#duration-format',
'attr' => [
'placeholder' => '00:00',
]
]);
$builder->addEventListener(
FormEvents::POST_SET_DATA,
function (FormEvent $event) {
/** @var Timesheet $data */
$data = $event->getData();
if (null === $data->getEnd()) {
$event->getForm()->get('duration')->setData(null);
}
}
);
// make sure that duration is mapped back to end field
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
/** @var Timesheet $data */
$data = $event->getData();
$duration = $data->getDuration();
$end = null;
if (null !== $duration) {
$end = clone $data->getBegin();
$end->modify('+ ' . $duration . 'seconds');
}
$data->setEnd($end);
}
);
} else {
$builder->add('end', DateTimePickerType::class, [
'label' => 'label.end',
@@ -270,7 +301,7 @@ class TimesheetEditForm extends AbstractType
'include_user' => false,
'include_exported' => false,
'include_rate' => true,
'docu_chapter' => 'timesheet',
'docu_chapter' => 'timesheet.html',
'method' => 'POST',
]);
}

View File

@@ -73,12 +73,18 @@ class DurationType extends AbstractType
}
},
function ($formatToInt) use ($formatter, $pattern) {
if (null === $formatToInt) {
return null;
}
if (empty($formatToInt)) {
return 0;
}
if (!preg_match($pattern, $formatToInt)) {
throw new TransformationFailedException('Invalid duration format given');
}
try {
return $formatter->parseDurationString($formatToInt);
} catch (\Exception $e) {

View File

@@ -55,7 +55,7 @@ class InvoiceRendererType extends AbstractType
return $choiceValue;
},
'translation_domain' => 'invoice-renderer',
'docu_chapter' => 'invoices',
'docu_chapter' => 'invoices.html',
]);
}

View File

@@ -0,0 +1,55 @@
<?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\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class CurrentUser
{
/**
* @var TokenStorageInterface
*/
protected $storage;
/**
* @var UserRepository
*/
protected $repository;
/**
* @param TokenStorageInterface $storage
* @param UserRepository $repository
*/
public function __construct(TokenStorageInterface $storage, UserRepository $repository)
{
$this->storage = $storage;
$this->repository = $repository;
}
/**
* @return User|null
*/
public function getUser()
{
if (null === $this->storage->getToken()) {
return null;
}
/** @var User $user */
$user = $this->storage->getToken()->getUser();
if (!($user instanceof User)) {
return null;
}
return $this->repository->getById($user->getId());
}
}

View File

@@ -33,5 +33,4 @@ interface RoundingInterface
* @param $minutes
*/
public function roundDuration(Timesheet $record, $minutes);
}

View File

@@ -10,7 +10,7 @@
namespace App\Timesheet;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use App\Security\CurrentUser;
class UserDateTimeFactory
{
@@ -20,18 +20,13 @@ class UserDateTimeFactory
protected $timezone;
/**
* @param TokenStorageInterface $tokenStorage
* @param CurrentUser $user
*/
public function __construct(TokenStorageInterface $tokenStorage)
public function __construct(CurrentUser $user)
{
if (null === $tokenStorage->getToken()) {
return;
}
/* @var $user User */
$user = $tokenStorage->getToken()->getUser();
$timezone = date_default_timezone_get();
$user = $user->getUser();
if ($user instanceof User && null !== $user->getPreferenceValue('timezone')) {
$timezone = $user->getPreferenceValue('timezone');
}

View File

@@ -9,6 +9,7 @@
namespace App\Twig;
use App\Constants;
use App\Entity\Timesheet;
use App\Utils\Duration;
use App\Utils\LocaleSettings;
@@ -121,6 +122,7 @@ class Extensions extends \Twig_Extension
new TwigFilter('currency', [$this, 'currency']),
new TwigFilter('country', [$this, 'country']),
new TwigFilter('icon', [$this, 'icon']),
new TwigFilter('docu_link', [$this, 'documentationLink']),
];
}
@@ -247,6 +249,15 @@ class Extensions extends \Twig_Extension
return self::$icons[$name] ?? $default;
}
/**
* @param string $url
* @return string
*/
public function documentationLink($url = '')
{
return Constants::HOMEPAGE . '/documentation/' . $url;
}
/**
* @param float $amount
* @param string $currency

View File

@@ -27,6 +27,7 @@ class Timesheet extends Constraint
public const DISABLED_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d87';
public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88';
public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89';
public const START_DISALLOWED = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d90';
protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
@@ -38,6 +39,7 @@ class Timesheet extends Constraint
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
self::START_DISALLOWED => 'You are not allowed to start this timesheet record.',
];
public $message = 'This timesheet has invalid settings.';

View File

@@ -11,6 +11,7 @@ namespace App\Validator\Constraints;
use App\Entity\Timesheet;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -18,17 +19,29 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class TimesheetValidator extends ConstraintValidator
{
/**
* @var AuthorizationCheckerInterface
*/
protected $auth;
/**
* @var array
*/
protected $rules = [];
/**
* @var bool
*/
protected $durationOnly = false;
/**
* @param AuthorizationCheckerInterface $auth
* @param array $ruleset
* @param bool $durationOnly
*/
public function __construct(array $ruleset)
public function __construct(AuthorizationCheckerInterface $auth, array $ruleset, bool $durationOnly)
{
$this->auth = $auth;
$this->rules = $ruleset;
$this->durationOnly = $durationOnly;
}
/**
@@ -61,6 +74,31 @@ class TimesheetValidator extends ConstraintValidator
$this->validateBeginAndEnd($value, $this->context);
$this->validateActivityAndProject($value, $this->context);
$this->validatePermissions($value, $this->context);
}
/**
* @param Timesheet $timesheet
* @param ExecutionContextInterface $context
*/
protected function validatePermissions(Timesheet $timesheet, ExecutionContextInterface $context)
{
// special case that would otherwise need to be validated in several controllers:
// an entry is edited and the end date is removed (or duration deleted) would restart the record,
// which might be disallowed for the current user
if ($context->getViolations()->count() == 0 && null === $timesheet->getEnd()) {
if (!$this->auth->isGranted('start', $timesheet)) {
$context->buildViolation('You are not allowed to start this timesheet record.')
->atPath($this->durationOnly ? 'duration' : 'end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::START_DISALLOWED)
->addViolation();
return;
}
}
// TODO check active entries against hard_limit
}
/**

View File

@@ -73,7 +73,6 @@
eventRender: function(eventObj, $el) {
if (eventObj.source.ajaxSettings.name !== 'kimaiUserTimeSource') {
return;
}
$el.popover({
title: eventObj.title,
@@ -98,17 +97,23 @@
});
},
eventClick: function(calEvent, jsEvent, view) {
{% if is_granted('edit_own_timesheet') %}
location.href = '{{ path('timesheet_edit', {id: '-XX-'}) }}?origin=calendar'.replace('-XX-', calEvent.id);
{% endif %}
},
dayClick: function(date, jsEvent, view) {
{% if is_granted('create_own_timesheet') %}
location.href = '{{ path('timesheet_create') }}' + '?origin=calendar&begin=' + date.format();
{% endif %}
},
selectable: true,
select: function( start, end, jsEvent, view) {
if(view.type === 'month') {
return;
}
{% if is_granted('create_own_timesheet') %}
location.href = '{{ path('timesheet_create') }}' + '?from=' + start.format() + '&to=' + end.format();
{% endif %}
}
})
})

View File

@@ -3,7 +3,7 @@
<h3 class="box-title">
{{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %}
<a href="{{ path('help_chapter', {'chapter': form.vars.docu_chapter}) }}"><i class="{{ 'help'|icon }}"></i></a>
<a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a>
{% endif %}
</h3>
</div>

View File

@@ -4,7 +4,7 @@
{% block modal_title %}
{{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %}
<a href="{{ path('help_chapter', {'chapter': form.vars.docu_chapter}) }}"><i class="{{ 'help'|icon }}"></i></a>
<a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block modal_body %}

View File

@@ -3,7 +3,7 @@
{# Adds the help icon, including a link to the documentation #}
{% block form_label %}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %}
<a href="{{ path('help_chapter', {'chapter': form.vars.docu_chapter}) }}"><i class="{{ 'help'|icon }}"></i></a>
<a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a>
{% endif %}
{{ parent() }}
{% endblock form_label %}

View File

@@ -32,10 +32,12 @@
</div>
{% endif %}
</li>
{% if is_granted('create_own_timesheet') %}
<li class="footer"><a href="{{ path('timesheet_create') }}">{{ 'timesheet.start'|trans }}</a></li>
{% endif %}
</ul>
</li>
{% else %}
{% elseif is_granted('create_own_timesheet') %}
<li class="messages-menu">
<a href="{{ path('timesheet_create') }}" class="ddt-large">
<i class="{{ 'start'|icon }} fa-2x"></i>

View File

@@ -1,4 +1,4 @@
{% if entries is defined and entries is not empty %}
{% if entries is defined and entries is not empty and is_granted('start_own_timesheet') %}
<li class="dropdown notifications-menu">
<a href="#" class="dropdown-toggle ddt-large" data-toggle="dropdown">
<i class="{{ 'activity'|icon }} fa-2x"></i>

View File

@@ -46,7 +46,7 @@
</li>
{% endif %}
<li>
<a href="{{ constant('App\\Constants::HOMEPAGE') }}/documentation/" target="_blank">
<a href="{{ ''|docu_link }}" target="_blank">
<div class="pull-left image">
<i class="{{ 'help'|icon }} text-gray"></i>
</div>

View File

@@ -226,7 +226,6 @@ abstract class ControllerBaseTest extends WebTestCase
if (count($validation) < 1) {
// decorated form fields with icon have a different html structure, see kimai-theme.html.twig
$classes = $field->parents()->getNode(1)->getAttribute('class');
$this->assertContains('has-feedback', $classes, 'Form field has no validation message: ' . $name);
$this->assertContains('has-error', $classes, 'Form field has no validation message: ' . $name);
}
}

View File

@@ -324,9 +324,8 @@ class TimesheetControllerTest extends ControllerBaseTest
$response = $client->getResponse();
$this->assertTrue($response->isSuccessful());
$docuUrl = $this->createUrl('/help/timesheet');
$this->assertContains(
'<a href="' . $docuUrl . '"><i class="far fa-question-circle"></i></a>',
'href="https://www.kimai.org/documentation/timesheet.html"',
$response->getContent(),
'Could not find link to documentation'
);

View File

@@ -40,8 +40,7 @@ abstract class AbstractEntityTest extends KernelTestCase
foreach ($violations as $validation) {
$violatedFields[$validation->getPropertyPath()] = $validation->getPropertyPath();
}
$this->assertEquals($expected, count($violatedFields), sprintf('Expected %s violations, found %s in %s.', $expected, $actual, implode(', ', array_keys($violatedFields))));
$countViolations = count($violatedFields);
foreach ($fieldNames as $id => $propertyPath) {
$foundField = false;
@@ -54,6 +53,7 @@ abstract class AbstractEntityTest extends KernelTestCase
}
$this->assertEmpty($violatedFields, sprintf('Unexpected violations found: %s', implode(', ', $violatedFields)));
$this->assertEquals($expected, $countViolations, sprintf('Expected %s violations, found %s in %s.', $expected, $actual, implode(', ', array_keys($violatedFields))));
}
protected function assertHasNoViolations($entity)

View File

@@ -11,6 +11,8 @@ namespace App\Tests\Export\Renderer;
use App\Entity\User;
use App\Export\Renderer\PDFRenderer;
use App\Repository\UserRepository;
use App\Security\CurrentUser;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
@@ -25,12 +27,17 @@ class PdfRendererTest extends AbstractRendererTest
{
protected function getDateTimeFactory()
{
$user = new User();
$repository = $this->getMockBuilder(UserRepository::class)->setMethods(['getById'])->disableOriginalConstructor()->getMock();
$repository->expects($this->once())->method('getById')->willReturn($user);
$token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects($this->once())->method('getUser')->willReturn(new User());
$token->expects($this->once())->method('getUser')->willReturn($user);
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);
return new UserDateTimeFactory($tokenStorage);
$user = new CurrentUser($tokenStorage, $repository);
return new UserDateTimeFactory($user);
}
public function testConfiguration()

View File

@@ -11,6 +11,8 @@ namespace App\Tests\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\UserRepository;
use App\Security\CurrentUser;
use App\Timesheet\UserDateTimeFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
@@ -24,18 +26,27 @@ class UserDateTimeFactoryTest extends TestCase
public const TEST_TIMEZONE = 'Antarctica/DumontDUrville';
protected function createDateTimeFactory(string $timezone)
{
return new UserDateTimeFactory($this->getCurrentUserMock($timezone));
}
protected function getCurrentUserMock($timezone = null)
{
$user = new User();
$pref = new UserPreference();
$pref->setName('timezone');
$pref->setValue($timezone);
$user->addPreference($pref);
if (null !== $timezone) {
$pref = new UserPreference();
$pref->setName('timezone');
$pref->setValue($timezone);
$user->addPreference($pref);
}
$repository = $this->getMockBuilder(UserRepository::class)->setMethods(['getById'])->disableOriginalConstructor()->getMock();
$repository->expects($this->once())->method('getById')->willReturn($user);
$token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects($this->once())->method('getUser')->willReturn($user);
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);
return new UserDateTimeFactory($tokenStorage);
return new CurrentUser($tokenStorage, $repository);
}
public function testGetTimezone()
@@ -46,12 +57,14 @@ class UserDateTimeFactoryTest extends TestCase
public function testGetTimezoneWithFallbackTimezone()
{
$repository = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
$token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects($this->once())->method('getUser')->willReturn('anonymous');
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);
$sut = new UserDateTimeFactory($tokenStorage);
$current = new CurrentUser($tokenStorage, $repository);
$sut = new UserDateTimeFactory($current);
$this->assertEquals(date_default_timezone_get(), $sut->getTimezone()->getName());
}

View File

@@ -47,7 +47,7 @@ class ExtensionsTest extends TestCase
public function testGetFilters()
{
$filters = ['duration', 'money', 'currency', 'country', 'icon'];
$filters = ['duration', 'money', 'currency', 'country', 'icon', 'docu_link'];
$sut = $this->getSut($this->localeDe);
$twigFilters = $sut->getFilters();
$this->assertCount(count($filters), $twigFilters);
@@ -227,4 +227,21 @@ class ExtensionsTest extends TestCase
$this->assertEquals('', $sut->icon('foo'));
$this->assertEquals('bar', $sut->icon('foo', 'bar'));
}
public function testDocuLink()
{
$data = [
'timesheet.html' => 'https://www.kimai.org/documentation/timesheet.html',
'timesheet.html#duration-format' => 'https://www.kimai.org/documentation/timesheet.html#duration-format',
'invoice.html' => 'https://www.kimai.org/documentation/invoice.html',
'' => 'https://www.kimai.org/documentation/',
null => 'https://www.kimai.org/documentation/',
];
$sut = $this->getSut($this->localeEn);
foreach ($data as $input => $expected) {
$result = $sut->documentationLink($input);
$this->assertEquals($expected, $result);
}
}
}

View File

@@ -15,6 +15,7 @@ use App\Entity\Project;
use App\Entity\Timesheet;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use App\Validator\Constraints\TimesheetValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
@@ -23,13 +24,16 @@ use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
*/
class TimesheetValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
protected function createValidator($isGranted = true)
{
$options = [
'allow_future_times' => false
];
return new TimesheetValidator($options);
$authMock = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
$authMock->method('isGranted')->willReturn($isGranted);
return new TimesheetValidator($authMock, $options, false);
}
/**
@@ -77,6 +81,33 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
->assertRaised();
}
public function testRestartDisallowed()
{
$this->validator = $this->createValidator(false);
$this->validator->initialize($this->context);
$begin = new \DateTime('-10 hour');
$customer = new Customer();
$activity = new Activity();
$project = new Project();
$project->setCustomer($customer);
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setActivity($activity)
->setProject($project)
;
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('You are not allowed to start this timesheet record.')
->atPath('property.path.end')
->setCode(TimesheetConstraint::START_DISALLOWED)
->assertRaised();
}
public function testEndBeforeBegin()
{
$end = new \DateTime('-10 hour');