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(), data: $form.serialize(),
success: function(html) { success: function(html) {
btn.button('reset'); 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); $.kimai.ajaxFormInModal(html);
} else { } else {
$.kimai.reloadDatatableWithToolbarFilter(); $.kimai.reloadDatatableWithToolbarFilter();

View File

@@ -114,6 +114,7 @@ services:
App\Validator\Constraints\TimesheetValidator: App\Validator\Constraints\TimesheetValidator:
arguments: arguments:
$ruleset: "%kimai.timesheet.rules%" $ruleset: "%kimai.timesheet.rules%"
$durationOnly: "%kimai.timesheet.duration_only%"
# ================================================================================ # ================================================================================
# THEME # 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.js": "/build/app.js?039b40a3efd42cd2846a",
"build/app.css": "/build/app.css?d76b5fe68db00a63c65d6dbde19c6342", "build/app.css": "/build/app.css?82e5f119685e8c17fbafec374d7a01e0",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd", "build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905", "build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29", "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); 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()) { if (null === $timesheet->getEnd()) {
$this->repository->stopActiveEntries( $this->repository->stopActiveEntries(
$timesheet->getUser(), $timesheet->getUser(),
@@ -289,17 +278,6 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view); 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 = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet); $entityManager->persist($timesheet);
$entityManager->flush(); $entityManager->flush();

View File

@@ -97,18 +97,6 @@ trait TimesheetControllerTrait
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { 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 = $this->getDoctrine()->getManager();
$entityManager->persist($entry); $entityManager->persist($entry);
$entityManager->flush(); $entityManager->flush();
@@ -174,17 +162,6 @@ trait TimesheetControllerTrait
$createForm->handleRequest($request); $createForm->handleRequest($request);
if ($createForm->isSubmitted() && $createForm->isValid()) { 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(); $entityManager = $this->getDoctrine()->getManager();
try { try {

View File

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

View File

@@ -117,7 +117,38 @@ class TimesheetEditForm extends AbstractType
if ($options['duration_only']) { if ($options['duration_only']) {
$builder->add('duration', DurationType::class, [ $builder->add('duration', DurationType::class, [
'required' => false, '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 { } else {
$builder->add('end', DateTimePickerType::class, [ $builder->add('end', DateTimePickerType::class, [
'label' => 'label.end', 'label' => 'label.end',
@@ -270,7 +301,7 @@ class TimesheetEditForm extends AbstractType
'include_user' => false, 'include_user' => false,
'include_exported' => false, 'include_exported' => false,
'include_rate' => true, 'include_rate' => true,
'docu_chapter' => 'timesheet', 'docu_chapter' => 'timesheet.html',
'method' => 'POST', 'method' => 'POST',
]); ]);
} }

View File

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

View File

@@ -55,7 +55,7 @@ class InvoiceRendererType extends AbstractType
return $choiceValue; return $choiceValue;
}, },
'translation_domain' => 'invoice-renderer', '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 * @param $minutes
*/ */
public function roundDuration(Timesheet $record, $minutes); public function roundDuration(Timesheet $record, $minutes);
} }

View File

@@ -10,7 +10,7 @@
namespace App\Timesheet; namespace App\Timesheet;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use App\Security\CurrentUser;
class UserDateTimeFactory class UserDateTimeFactory
{ {
@@ -20,18 +20,13 @@ class UserDateTimeFactory
protected $timezone; 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(); $timezone = date_default_timezone_get();
$user = $user->getUser();
if ($user instanceof User && null !== $user->getPreferenceValue('timezone')) { if ($user instanceof User && null !== $user->getPreferenceValue('timezone')) {
$timezone = $user->getPreferenceValue('timezone'); $timezone = $user->getPreferenceValue('timezone');
} }

View File

@@ -9,6 +9,7 @@
namespace App\Twig; namespace App\Twig;
use App\Constants;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Utils\Duration; use App\Utils\Duration;
use App\Utils\LocaleSettings; use App\Utils\LocaleSettings;
@@ -121,6 +122,7 @@ class Extensions extends \Twig_Extension
new TwigFilter('currency', [$this, 'currency']), new TwigFilter('currency', [$this, 'currency']),
new TwigFilter('country', [$this, 'country']), new TwigFilter('country', [$this, 'country']),
new TwigFilter('icon', [$this, 'icon']), 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; return self::$icons[$name] ?? $default;
} }
/**
* @param string $url
* @return string
*/
public function documentationLink($url = '')
{
return Constants::HOMEPAGE . '/documentation/' . $url;
}
/** /**
* @param float $amount * @param float $amount
* @param string $currency * @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_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d87';
public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88'; public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88';
public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89'; public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89';
public const START_DISALLOWED = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d90';
protected static $errorNames = [ protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.', 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_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.', self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.', 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.'; public $message = 'This timesheet has invalid settings.';

View File

@@ -11,6 +11,7 @@ namespace App\Validator\Constraints;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Validator\Constraints\Timesheet as TimesheetConstraint; use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -18,17 +19,29 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class TimesheetValidator extends ConstraintValidator class TimesheetValidator extends ConstraintValidator
{ {
/**
* @var AuthorizationCheckerInterface
*/
protected $auth;
/** /**
* @var array * @var array
*/ */
protected $rules = []; protected $rules = [];
/**
* @var bool
*/
protected $durationOnly = false;
/** /**
* @param AuthorizationCheckerInterface $auth
* @param array $ruleset * @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->rules = $ruleset;
$this->durationOnly = $durationOnly;
} }
/** /**
@@ -61,6 +74,31 @@ class TimesheetValidator extends ConstraintValidator
$this->validateBeginAndEnd($value, $this->context); $this->validateBeginAndEnd($value, $this->context);
$this->validateActivityAndProject($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) { eventRender: function(eventObj, $el) {
if (eventObj.source.ajaxSettings.name !== 'kimaiUserTimeSource') { if (eventObj.source.ajaxSettings.name !== 'kimaiUserTimeSource') {
return; return;
} }
$el.popover({ $el.popover({
title: eventObj.title, title: eventObj.title,
@@ -98,17 +97,23 @@
}); });
}, },
eventClick: function(calEvent, jsEvent, view) { eventClick: function(calEvent, jsEvent, view) {
{% if is_granted('edit_own_timesheet') %}
location.href = '{{ path('timesheet_edit', {id: '-XX-'}) }}?origin=calendar'.replace('-XX-', calEvent.id); location.href = '{{ path('timesheet_edit', {id: '-XX-'}) }}?origin=calendar'.replace('-XX-', calEvent.id);
{% endif %}
}, },
dayClick: function(date, jsEvent, view) { dayClick: function(date, jsEvent, view) {
{% if is_granted('create_own_timesheet') %}
location.href = '{{ path('timesheet_create') }}' + '?origin=calendar&begin=' + date.format(); location.href = '{{ path('timesheet_create') }}' + '?origin=calendar&begin=' + date.format();
{% endif %}
}, },
selectable: true, selectable: true,
select: function( start, end, jsEvent, view) { select: function( start, end, jsEvent, view) {
if(view.type === 'month') { if(view.type === 'month') {
return; return;
} }
{% if is_granted('create_own_timesheet') %}
location.href = '{{ path('timesheet_create') }}' + '?from=' + start.format() + '&to=' + end.format(); location.href = '{{ path('timesheet_create') }}' + '?from=' + start.format() + '&to=' + end.format();
{% endif %}
} }
}) })
}) })

View File

@@ -3,7 +3,7 @@
<h3 class="box-title"> <h3 class="box-title">
{{ title }} {{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %} {% 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 %} {% endif %}
</h3> </h3>
</div> </div>

View File

@@ -4,7 +4,7 @@
{% block modal_title %} {% block modal_title %}
{{ title }} {{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %} {% 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 %} {% endif %}
{% endblock %} {% endblock %}
{% block modal_body %} {% block modal_body %}

View File

@@ -3,7 +3,7 @@
{# Adds the help icon, including a link to the documentation #} {# Adds the help icon, including a link to the documentation #}
{% block form_label %} {% block form_label %}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %} {% 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 %} {% endif %}
{{ parent() }} {{ parent() }}
{% endblock form_label %} {% endblock form_label %}

View File

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

View File

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

View File

@@ -226,7 +226,6 @@ abstract class ControllerBaseTest extends WebTestCase
if (count($validation) < 1) { if (count($validation) < 1) {
// decorated form fields with icon have a different html structure, see kimai-theme.html.twig // decorated form fields with icon have a different html structure, see kimai-theme.html.twig
$classes = $field->parents()->getNode(1)->getAttribute('class'); $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); $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(); $response = $client->getResponse();
$this->assertTrue($response->isSuccessful()); $this->assertTrue($response->isSuccessful());
$docuUrl = $this->createUrl('/help/timesheet');
$this->assertContains( $this->assertContains(
'<a href="' . $docuUrl . '"><i class="far fa-question-circle"></i></a>', 'href="https://www.kimai.org/documentation/timesheet.html"',
$response->getContent(), $response->getContent(),
'Could not find link to documentation' 'Could not find link to documentation'
); );

View File

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

View File

@@ -11,6 +11,8 @@ namespace App\Tests\Export\Renderer;
use App\Entity\User; use App\Entity\User;
use App\Export\Renderer\PDFRenderer; use App\Export\Renderer\PDFRenderer;
use App\Repository\UserRepository;
use App\Security\CurrentUser;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
@@ -25,12 +27,17 @@ class PdfRendererTest extends AbstractRendererTest
{ {
protected function getDateTimeFactory() 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 = $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 = new TokenStorage();
$tokenStorage->setToken($token); $tokenStorage->setToken($token);
return new UserDateTimeFactory($tokenStorage); $user = new CurrentUser($tokenStorage, $repository);
return new UserDateTimeFactory($user);
} }
public function testConfiguration() public function testConfiguration()

View File

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

View File

@@ -47,7 +47,7 @@ class ExtensionsTest extends TestCase
public function testGetFilters() public function testGetFilters()
{ {
$filters = ['duration', 'money', 'currency', 'country', 'icon']; $filters = ['duration', 'money', 'currency', 'country', 'icon', 'docu_link'];
$sut = $this->getSut($this->localeDe); $sut = $this->getSut($this->localeDe);
$twigFilters = $sut->getFilters(); $twigFilters = $sut->getFilters();
$this->assertCount(count($filters), $twigFilters); $this->assertCount(count($filters), $twigFilters);
@@ -227,4 +227,21 @@ class ExtensionsTest extends TestCase
$this->assertEquals('', $sut->icon('foo')); $this->assertEquals('', $sut->icon('foo'));
$this->assertEquals('bar', $sut->icon('foo', 'bar')); $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\Entity\Timesheet;
use App\Validator\Constraints\Timesheet as TimesheetConstraint; use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use App\Validator\Constraints\TimesheetValidator; use App\Validator\Constraints\TimesheetValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
@@ -23,13 +24,16 @@ use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
*/ */
class TimesheetValidatorTest extends ConstraintValidatorTestCase class TimesheetValidatorTest extends ConstraintValidatorTestCase
{ {
protected function createValidator() protected function createValidator($isGranted = true)
{ {
$options = [ $options = [
'allow_future_times' => false '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(); ->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() public function testEndBeforeBegin()
{ {
$end = new \DateTime('-10 hour'); $end = new \DateTime('-10 hour');