release 1.17.2 (#3135)

* allow to input password interactively in console
* added block to simplify overwriting export template parts
* prevent installation in PHP 8.1
* composer update
* phpunit version to 9
* support negative amounts in excel export
* added system configuration actions for calendar, weekly timesheet, users
* added method to render text with full markdown support
This commit is contained in:
Kevin Papst
2022-02-14 17:33:52 +01:00
committed by GitHub
parent 4cb0ac37a6
commit 83a894823f
37 changed files with 1298 additions and 892 deletions

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy: strategy:
matrix: matrix:
php: ['7.4'] php: ['8.0']
name: Coverage (${{ matrix.php }}) name: Coverage (${{ matrix.php }})
steps: steps:

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy: strategy:
matrix: matrix:
php: ['7.3', '7.4', '8.0'] php: ['7.4', '8.0', '8.1']
name: Integration (${{ matrix.php }}) name: Integration (${{ matrix.php }})
steps: steps:

View File

@@ -10,7 +10,7 @@
} }
], ],
"require": { "require": {
"php": ">=7.3", "php": "7.3.*||7.4.*||8.0.*",
"ext-gd": "*", "ext-gd": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-json": "*", "ext-json": "*",
@@ -78,12 +78,12 @@
"dama/doctrine-test-bundle": "^6.0", "dama/doctrine-test-bundle": "^6.0",
"doctrine/doctrine-fixtures-bundle": "^3.2", "doctrine/doctrine-fixtures-bundle": "^3.2",
"fakerphp/faker": "^1.15", "fakerphp/faker": "^1.15",
"friendsofphp/php-cs-fixer": "3.2.*", "friendsofphp/php-cs-fixer": "^3.2",
"phpstan/phpstan": "^1.0", "phpstan/phpstan": "^1.0",
"phpstan/phpstan-doctrine": "^1.0", "phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-symfony": "^1.0", "phpstan/phpstan-symfony": "^1.0",
"phpunit/phpunit": "^8.0", "phpunit/phpunit": "^9.0",
"symfony/browser-kit": "^4.4", "symfony/browser-kit": "^4.4",
"symfony/css-selector": "^4.4", "symfony/css-selector": "^4.4",
"symfony/debug-bundle": "^4.4", "symfony/debug-bundle": "^4.4",

1765
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php">
<php> <php>
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1" /> <ini name="max_execution_time" value="-1"/>
<ini name="intl.default_locale" value="en_US" /> <ini name="intl.default_locale" value="en_US"/>
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/> <env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/> <env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
<env name="APP_ENV" value="test" force="true"/> <env name="APP_ENV" value="test" force="true"/>
@@ -23,7 +20,6 @@
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/> <env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
<env name="MAILER_URL" value="null://null"/> <env name="MAILER_URL" value="null://null"/>
<env name="MAILER_FROM" value="kimai@example.com"/> <env name="MAILER_FROM" value="kimai@example.com"/>
<!-- <!--
REINSTALL THE TEST DATABASE, eg. AFTER CHANGING STRUCTURE! REINSTALL THE TEST DATABASE, eg. AFTER CHANGING STRUCTURE!
@@ -41,10 +37,11 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<filter> <coverage processUncoveredFiles="true">
<whitelist processUncoveredFilesFromWhitelist="true"> <include>
<directory suffix=".php">src/</directory> <directory suffix=".php">src/</directory>
<directory suffix=".php">templates/</directory> <directory suffix=".php">templates/</directory>
</include>
<exclude> <exclude>
<directory suffix=".php">src/Migrations/</directory> <directory suffix=".php">src/Migrations/</directory>
<directory suffix=".php">assets/</directory> <directory suffix=".php">assets/</directory>
@@ -57,18 +54,17 @@
<directory suffix=".php">var/</directory> <directory suffix=".php">var/</directory>
<directory suffix=".php">vendor/</directory> <directory suffix=".php">vendor/</directory>
</exclude> </exclude>
</whitelist> </coverage>
</filter>
<!-- <!--
wrap tests inside database transactions, so tests can safely manipulate contents without wrap tests inside database transactions, so tests can safely manipulate contents without
affecting other tests. @see https://github.com/dmaicher/doctrine-test-bundle affecting other tests. @see https://github.com/dmaicher/doctrine-test-bundle
--> -->
<extensions> <extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" /> <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions> </extensions>
<listeners> <listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" /> <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
</listeners> </listeners>
</phpunit> </phpunit>

View File

@@ -0,0 +1,40 @@
<?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\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
abstract class AbstractUserCommand extends Command
{
protected function askForPassword(InputInterface $input, OutputInterface $output): string
{
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password)) {
throw new \Exception('The password may not be empty');
}
return $value;
});
$passwordQuestion->setMaxAttempts(3);
return $helper->ask($input, $output, $passwordQuestion);
}
}

View File

@@ -12,12 +12,11 @@ namespace App\Command;
use App\User\UserService; use App\User\UserService;
use App\Utils\CommandStyle; use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException; use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class ChangePasswordCommand extends Command final class ChangePasswordCommand extends AbstractUserCommand
{ {
private $userService; private $userService;
@@ -35,7 +34,7 @@ class ChangePasswordCommand extends Command
->setDescription('Change the password of a user.') ->setDescription('Change the password of a user.')
->setDefinition([ ->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'), new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::REQUIRED, 'The password'), new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
]) ])
->setHelp( ->setHelp(
<<<'EOT' <<<'EOT'
@@ -59,7 +58,12 @@ EOT
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$username = $input->getArgument('username'); $username = $input->getArgument('username');
if (null !== $input->getArgument('password')) {
$password = $input->getArgument('password'); $password = $input->getArgument('password');
} else {
$password = $this->askForPassword($input, $output);
}
$user = $this->userService->findUserByUsernameOrThrowException($username); $user = $this->userService->findUserByUsernameOrThrowException($username);

View File

@@ -13,14 +13,11 @@ use App\Entity\User;
use App\User\UserService; use App\User\UserService;
use App\Utils\CommandStyle; use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException; use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class CreateUserCommand extends Command final class CreateUserCommand extends AbstractUserCommand
{ {
private $userService; private $userService;
@@ -91,31 +88,4 @@ final class CreateUserCommand extends Command
return 0; return 0;
} }
/**
* @param InputInterface $input
* @param OutputInterface $output
*
* @return string
*/
protected function askForPassword(InputInterface $input, OutputInterface $output): string
{
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password)) {
throw new \Exception('The password may not be empty');
}
return $value;
});
$passwordQuestion->setMaxAttempts(3);
return $helper->ask($input, $output, $passwordQuestion);
}
} }

View File

@@ -21,10 +21,7 @@ final class ThemeConfiguration implements \ArrayAccess
$this->systemConfiguration = $systemConfiguration; $this->systemConfiguration = $systemConfiguration;
} }
/** public function offsetExists($offset): bool
* @return bool
*/
public function offsetExists($offset)
{ {
return $this->systemConfiguration->has('theme.' . $offset); return $this->systemConfiguration->has('theme.' . $offset);
} }
@@ -42,7 +39,7 @@ final class ThemeConfiguration implements \ArrayAccess
* @param mixed $value * @param mixed $value
* @throws \BadMethodCallException * @throws \BadMethodCallException
*/ */
public function offsetSet($offset, $value) public function offsetSet($offset, $value): void
{ {
throw new \BadMethodCallException('ThemeConfiguration does not support offsetSet()'); throw new \BadMethodCallException('ThemeConfiguration does not support offsetSet()');
} }
@@ -51,7 +48,7 @@ final class ThemeConfiguration implements \ArrayAccess
* @param mixed $offset * @param mixed $offset
* @throws \BadMethodCallException * @throws \BadMethodCallException
*/ */
public function offsetUnset($offset) public function offsetUnset($offset): void
{ {
throw new \BadMethodCallException('ThemeConfiguration does not support offsetUnset()'); throw new \BadMethodCallException('ThemeConfiguration does not support offsetUnset()');
} }

View File

@@ -24,6 +24,10 @@ class CalendarSubscriber extends AbstractActionsSubscriber
$event->addCreate($this->path('timesheet_create')); $event->addCreate($this->path('timesheet_create'));
} }
if ($this->isGranted('system_configuration')) {
$event->addAction('settings', ['url' => $this->path('system_configuration_section', ['section' => 'calendar']), 'class' => 'modal-ajax-form']);
}
$event->addHelp($this->documentationLink('calendar.html')); $event->addHelp($this->documentationLink('calendar.html'));
} }
} }

View File

@@ -20,6 +20,10 @@ class QuickEntrySubscriber extends AbstractActionsSubscriber
public function onActions(PageActionsEvent $event): void public function onActions(PageActionsEvent $event): void
{ {
if ($this->isGranted('system_configuration')) {
$event->addAction('settings', ['url' => $this->path('system_configuration_section', ['section' => 'quick_entry']), 'class' => 'modal-ajax-form']);
}
$event->addHelp($this->documentationLink('weekly-times.html')); $event->addHelp($this->documentationLink('weekly-times.html'));
} }
} }

View File

@@ -38,6 +38,10 @@ class UsersSubscriber extends AbstractActionsSubscriber
$event->addCreate($this->path('admin_user_create'), false); $event->addCreate($this->path('admin_user_create'), false);
} }
if ($this->isGranted('system_configuration')) {
$event->addAction('settings', ['url' => $this->path('system_configuration_section', ['section' => 'user']), 'class' => 'modal-ajax-form']);
}
$event->addHelp($this->documentationLink('users.html')); $event->addHelp($this->documentationLink('users.html'));
} }
} }

View File

@@ -45,8 +45,10 @@ abstract class AbstractSpreadsheetRenderer
public const TIME_FORMAT = 'hh:mm'; public const TIME_FORMAT = 'hh:mm';
public const DURATION_FORMAT = '[hh]:mm'; public const DURATION_FORMAT = '[hh]:mm';
public const DURATION_DECIMAL = '#0.00'; public const DURATION_DECIMAL = '#0.00';
// https://support.microsoft.com/de-de/office/zahlenformatcodes-5026bbd6-04bc-48cd-bf33-80f18b4eae68
// Part 1 = positive; Part 2 = negative; Part 3 = zero; Part 4 = Text
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]'; public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* \(#,##0.00\);_("%1$s"* "-"??_);_(@_)'; public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* -#,##0.00;_("%1$s"* "-"??_);_(@_)';
public const RATE_FORMAT = self::RATE_FORMAT_LEFT; public const RATE_FORMAT = self::RATE_FORMAT_LEFT;
protected $durationFormat = self::DURATION_FORMAT; protected $durationFormat = self::DURATION_FORMAT;

View File

@@ -49,11 +49,11 @@ class PDFRenderer
*/ */
private $pdfOptions = []; private $pdfOptions = [];
public function __construct(Environment $twig, HtmlToPdfConverter $converter, ProjectStatisticService $projectRepository) public function __construct(Environment $twig, HtmlToPdfConverter $converter, ProjectStatisticService $projectStatisticService)
{ {
$this->twig = $twig; $this->twig = $twig;
$this->converter = $converter; $this->converter = $converter;
$this->projectStatisticService = $projectRepository; $this->projectStatisticService = $projectStatisticService;
} }
protected function getTemplate(): string protected function getTemplate(): string

View File

@@ -53,7 +53,6 @@ class TimesheetEditForm extends AbstractType
$project = null; $project = null;
$customer = null; $customer = null;
$currency = false; $currency = false;
$begin = null;
$customerCount = $this->customers->countCustomer(true); $customerCount = $this->customers->countCustomer(true);
$timezone = $options['timezone']; $timezone = $options['timezone'];
$isNew = true; $isNew = true;

View File

@@ -126,6 +126,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
*/ */
public function markdownToHtml(string $content): string public function markdownToHtml(string $content): string
{ {
return $this->markdown->toHtml($content); return $this->markdown->withFullMarkdownSupport($content);
} }
} }

View File

@@ -33,14 +33,14 @@ class Duration
* @param string $format * @param string $format
* @return string|null * @return string|null
*/ */
public function format($seconds, $format = self::FORMAT_NO_SECONDS) public function format(?int $seconds, string $format = self::FORMAT_NO_SECONDS)
{ {
if (null === $seconds) { if (null === $seconds) {
return null; return null;
} }
$hour = floor($seconds / 3600); $hour = (int) floor($seconds / 3600);
$minute = floor(($seconds / 60) % 60); $minute = (int) floor((int) ($seconds / 60) % 60);
$hour = $hour > 9 ? $hour : '0' . $hour; $hour = $hour > 9 ? $hour : '0' . $hour;
$minute = $minute > 9 ? $minute : '0' . $minute; $minute = $minute > 9 ? $minute : '0' . $minute;
@@ -60,7 +60,7 @@ class Duration
* @param string $duration * @param string $duration
* @return int * @return int
*/ */
public function parseDurationString($duration): int public function parseDurationString(string $duration): int
{ {
if (false !== stripos($duration, ':')) { if (false !== stripos($duration, ':')) {
return $this->parseDuration($duration, self::FORMAT_COLON); return $this->parseDuration($duration, self::FORMAT_COLON);

View File

@@ -83,7 +83,6 @@ class MPdfConverter implements HtmlToPdfConverter
for ($i = 0; $i < \count($parts); $i++) { for ($i = 0; $i < \count($parts); $i++) {
if (stripos($parts[$i], '<!-- CONTENT_PART -->') !== false) { if (stripos($parts[$i], '<!-- CONTENT_PART -->') !== false) {
$subParts = explode('<!-- CONTENT_PART -->', $parts[$i]); $subParts = explode('<!-- CONTENT_PART -->', $parts[$i]);
$run = 0;
foreach ($subParts as $subPart) { foreach ($subParts as $subPart) {
$mpdf->WriteHTML($subPart); $mpdf->WriteHTML($subPart);
} }

View File

@@ -10,7 +10,7 @@
namespace App\Utils; namespace App\Utils;
/** /**
* A simple class to parse markdown syntax and return HTML. * Parse markdown syntax and return HTML.
*/ */
final class Markdown final class Markdown
{ {
@@ -19,20 +19,14 @@ final class Markdown
*/ */
private $parser; private $parser;
public function __construct() public function toHtml(string $text, bool $safe = true): string
{ {
if ($this->parser === null) {
$this->parser = new ParsedownExtension(); $this->parser = new ParsedownExtension();
$this->parser->setUrlsLinked(true); $this->parser->setUrlsLinked(true);
$this->parser->setBreaksEnabled(true); $this->parser->setBreaksEnabled(true);
} }
/**
* @param string $text
* @param bool $safe
* @return string
*/
public function toHtml(string $text, bool $safe = true): string
{
if ($safe !== true) { if ($safe !== true) {
@trigger_error('Only safe mode is supported in Markdown since 1.16.3 to prevent XSS attacks. Parameter $safe will be removed with 2.0', E_USER_DEPRECATED); @trigger_error('Only safe mode is supported in Markdown since 1.16.3 to prevent XSS attacks. Parameter $safe will be removed with 2.0', E_USER_DEPRECATED);
} }
@@ -42,4 +36,15 @@ final class Markdown
return $this->parser->text($text); return $this->parser->text($text);
} }
public function withFullMarkdownSupport(string $text): string
{
$parser = new \Parsedown();
$parser->setUrlsLinked(true);
$parser->setBreaksEnabled(true);
$parser->setSafeMode(true);
$parser->setMarkupEscaped(true);
return $parser->text($text);
}
} }

View File

@@ -24,7 +24,7 @@ class ParsedownExtension extends \Parsedown
protected $BlockTypes = [ protected $BlockTypes = [
'*' => ['Rule', 'List'], '*' => ['Rule', 'List'],
'+' => ['List'], '+' => ['List'],
'-' => ['SetextHeader', 'Table', 'Rule', 'List'], '-' => ['Table', 'Rule', 'List'],
'0' => ['List'], '0' => ['List'],
'1' => ['List'], '1' => ['List'],
'2' => ['List'], '2' => ['List'],
@@ -37,7 +37,6 @@ class ParsedownExtension extends \Parsedown
'9' => ['List'], '9' => ['List'],
':' => ['Table'], ':' => ['Table'],
'<' => ['Comment', 'Markup'], '<' => ['Comment', 'Markup'],
'=' => ['SetextHeader'],
'>' => ['Quote'], '>' => ['Quote'],
'[' => ['Reference'], '[' => ['Reference'],
'_' => ['Rule'], '_' => ['Rule'],
@@ -124,7 +123,7 @@ class ParsedownExtension extends \Parsedown
* @param string $text * @param string $text
* @return string * @return string
*/ */
private function getIDfromText($text) private function getIDfromText($text): string
{ {
$text = strtolower($text); $text = strtolower($text);

View File

@@ -291,15 +291,15 @@
"phpunit/php-file-iterator": { "phpunit/php-file-iterator": {
"version": "1.4.5" "version": "1.4.5"
}, },
"phpunit/php-invoker": {
"version": "3.1.1"
},
"phpunit/php-text-template": { "phpunit/php-text-template": {
"version": "1.2.1" "version": "1.2.1"
}, },
"phpunit/php-timer": { "phpunit/php-timer": {
"version": "1.0.9" "version": "1.0.9"
}, },
"phpunit/php-token-stream": {
"version": "2.0.2"
},
"phpunit/phpunit": { "phpunit/phpunit": {
"version": "4.7", "version": "4.7",
"recipe": { "recipe": {
@@ -333,12 +333,21 @@
"robrichards/xmlseclibs": { "robrichards/xmlseclibs": {
"version": "3.0.4" "version": "3.0.4"
}, },
"sebastian/cli-parser": {
"version": "1.0.1"
},
"sebastian/code-unit": {
"version": "1.0.8"
},
"sebastian/code-unit-reverse-lookup": { "sebastian/code-unit-reverse-lookup": {
"version": "1.0.1" "version": "1.0.1"
}, },
"sebastian/comparator": { "sebastian/comparator": {
"version": "2.1.1" "version": "2.1.1"
}, },
"sebastian/complexity": {
"version": "2.0.2"
},
"sebastian/diff": { "sebastian/diff": {
"version": "2.0.1" "version": "2.0.1"
}, },
@@ -351,6 +360,9 @@
"sebastian/global-state": { "sebastian/global-state": {
"version": "2.0.0" "version": "2.0.0"
}, },
"sebastian/lines-of-code": {
"version": "1.0.3"
},
"sebastian/object-enumerator": { "sebastian/object-enumerator": {
"version": "3.0.3" "version": "3.0.3"
}, },

View File

@@ -35,7 +35,7 @@
</div> </div>
{{ widgets.user_avatar(comment.createdBy, false, 'direct-chat-img img-sm') }} {{ widgets.user_avatar(comment.createdBy, false, 'direct-chat-img img-sm') }}
<div class="direct-chat-text"> <div class="direct-chat-text">
{{ comment.message|replace(replacer)|md2html }} {{ comment.message|replace(replacer)|comment2html }}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -103,7 +103,9 @@
<sethtmlpagefooter name="myfooter" value="on" /> <sethtmlpagefooter name="myfooter" value="on" />
mpdf--> mpdf-->
{% endblock %} {% endblock %}
{% block body_start %}{% endblock %}
{% block summary %} {% block summary %}
{% block summary_header %}
<h2 style="margin-bottom: 4px; padding-bottom: 0">{% block title %}{{ 'export.document_title'|trans }}{% endblock %}</h2> <h2 style="margin-bottom: 4px; padding-bottom: 0">{% block title %}{{ 'export.document_title'|trans }}{% endblock %}</h2>
{% if query is defined %} {% if query is defined %}
<p> <p>
@@ -112,6 +114,7 @@ mpdf-->
</p> </p>
{% endif %} {% endif %}
<h3>{{ 'export.summary'|trans }}</h3> <h3>{{ 'export.summary'|trans }}</h3>
{% endblock %}
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
@@ -254,7 +257,9 @@ mpdf-->
<pagebreak> <pagebreak>
{% endblock %} {% endblock %}
{% block items %} {% block items %}
{% block items_header %}
<h3>{{ 'export.full_list'|trans }}</h3> <h3>{{ 'export.full_list'|trans }}</h3>
{% endblock %}
{% set duration = 0 %} {% set duration = 0 %}
{% set rate = 0 %} {% set rate = 0 %}
@@ -338,5 +343,6 @@ mpdf-->
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}
{% block body_end %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -6,9 +6,9 @@
{% import "invoice/macros.html.twig" as macros %} {% import "invoice/macros.html.twig" as macros %}
{% set columns = { {% set columns = {
'date': {'class': 'alwaysVisible'}, 'date': {'class': 'alwaysVisible text-nowrap'},
'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false}, 'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false},
'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false}, 'customer': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans}, 'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
} %} } %}
{% for field in metaColumns %} {% for field in metaColumns %}

View File

@@ -20,6 +20,7 @@ use Symfony\Component\Console\Tester\CommandTester;
/** /**
* @covers \App\Command\ChangePasswordCommand * @covers \App\Command\ChangePasswordCommand
* @covers \App\Command\AbstractUserCommand
* @group integration * @group integration
*/ */
class ChangePasswordCommandTest extends KernelTestCase class ChangePasswordCommandTest extends KernelTestCase
@@ -59,6 +60,7 @@ class ChangePasswordCommandTest extends KernelTestCase
$input = [ $input = [
'command' => $command->getName(), 'command' => $command->getName(),
]; ];
$interactive = false;
if ($username !== null) { if ($username !== null) {
$input['username'] = $username; $input['username'] = $username;
@@ -66,10 +68,19 @@ class ChangePasswordCommandTest extends KernelTestCase
if ($password !== null) { if ($password !== null) {
$input['password'] = $password; $input['password'] = $password;
} else {
$interactive = true;
} }
$commandTester = new CommandTester($command); $commandTester = new CommandTester($command);
$commandTester->execute($input);
$options = [];
if ($interactive) {
$options = ['interactive' => true];
$commandTester->setInputs(['12345678']);
}
$commandTester->execute($input, $options);
return $commandTester; return $commandTester;
} }
@@ -100,11 +111,10 @@ class ChangePasswordCommandTest extends KernelTestCase
$this->callCommand(null, '1234567890'); $this->callCommand(null, '1234567890');
} }
public function testWithMissingPassword() public function testWithMissingPasswordAsksForPassword()
{ {
$this->expectException(RuntimeException::class); $commandTester = $this->callCommand('john_user', null);
$this->expectExceptionMessage('Not enough arguments (missing: "password").'); $output = $commandTester->getDisplay();
$this->assertStringContainsString('[OK] Changed password for user "john_user".', $output);
$this->callCommand('1234567890', null);
} }
} }

View File

@@ -16,7 +16,6 @@ use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\Saml\SamlAuthFactoryFactory; use App\Tests\Mocks\Saml\SamlAuthFactoryFactory;
use OneLogin\Saml2\Auth; use OneLogin\Saml2\Auth;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use PHPUnit\Util\Xml;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
@@ -83,7 +82,7 @@ class SamlControllerTest extends TestCase
public function testMetadataAction() public function testMetadataAction()
{ {
$expected = <<<EOD $expectedXmlString = <<<EOD
<?xml version="1.0"?> <?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" validUntil="2020-07-23T10:26:50Z" cacheDuration="PT604800S" entityID="https://127.0.0.1:8010/auth/saml/metadata"> <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" validUntil="2020-07-23T10:26:50Z" cacheDuration="PT604800S" entityID="https://127.0.0.1:8010/auth/saml/metadata">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> <md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
@@ -118,8 +117,13 @@ EOD;
self::assertInstanceOf(Response::class, $result); self::assertInstanceOf(Response::class, $result);
self::assertEquals('xml', $result->headers->get('Content-Type')); self::assertEquals('xml', $result->headers->get('Content-Type'));
$expected = Xml::load($expected); $expected = new \DOMDocument();
$actual = Xml::load($result->getContent()); $tmp = $expected->loadXML($expectedXmlString);
self::assertTrue($tmp);
$actual = new \DOMDocument();
$tmp = $actual->loadXML($result->getContent());
self::assertTrue($tmp);
// the "validUntil" attribute in the outer node changes per request // the "validUntil" attribute in the outer node changes per request
self::assertEquals($expected->firstChild->firstChild, $actual->firstChild->firstChild); self::assertEquals($expected->firstChild->firstChild, $actual->firstChild->firstChild);

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Tests\Configuration\TestConfigLoader; use App\Tests\Configuration\TestConfigLoader;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
@@ -33,6 +34,11 @@ class CalendarControllerTest extends ControllerBaseTest
$this->request($client, '/calendar/'); $this->request($client, '/calendar/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertPageActions($client, [
'create modal-ajax-form' => $this->createUrl('/timesheet/create'),
'help' => 'https://www.kimai.org/documentation/calendar.html'
]);
$crawler = $client->getCrawler(); $crawler = $client->getCrawler();
$calendar = $crawler->filter('div#timesheet_calendar'); $calendar = $crawler->filter('div#timesheet_calendar');
$this->assertEquals(1, $calendar->count()); $this->assertEquals(1, $calendar->count());
@@ -40,6 +46,18 @@ class CalendarControllerTest extends ControllerBaseTest
$this->assertEquals(1, $dragAndDropBoxes->count()); $this->assertEquals(1, $dragAndDropBoxes->count());
} }
public function testCalendarActionAsSuperAdmin()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/calendar/');
$this->assertPageActions($client, [
'create modal-ajax-form' => $this->createUrl('/timesheet/create'),
'settings modal-ajax-form' => $this->createUrl('/admin/system-config/edit/calendar'),
'help' => 'https://www.kimai.org/documentation/calendar.html'
]);
}
public function testCalendarActionWithGoogleSource() public function testCalendarActionWithGoogleSource()
{ {
$loader = new TestConfigLoader([]); $loader = new TestConfigLoader([]);

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerComment; use App\Entity\CustomerComment;
use App\Entity\CustomerMeta; use App\Entity\CustomerMeta;
@@ -161,6 +162,13 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details')); $this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect(); $client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text'); $node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('A beautiful and short comment **with some** markdown formatting', $node->html());
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$configService = static::$kernel->getContainer()->get(SystemConfiguration::class);
$configService->offsetSet('timesheet.markdown_content', true);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html()); self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
} }

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityMeta; use App\Entity\ActivityMeta;
use App\Entity\ActivityRate; use App\Entity\ActivityRate;
@@ -260,6 +261,13 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details')); $this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect(); $client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text'); $node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('A beautiful and long comment **with some** markdown formatting', $node->html());
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$configService = static::$kernel->getContainer()->get(SystemConfiguration::class);
$configService->offsetSet('timesheet.markdown_content', true);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html()); self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
} }

View File

@@ -39,6 +39,7 @@ class UserControllerTest extends ControllerBaseTest
'visibility' => '#', 'visibility' => '#',
'download toolbar-action' => $this->createUrl('/admin/user/export'), 'download toolbar-action' => $this->createUrl('/admin/user/export'),
'create' => $this->createUrl('/admin/user/create'), 'create' => $this->createUrl('/admin/user/create'),
'settings modal-ajax-form' => $this->createUrl('/admin/system-config/edit/user'),
'help' => 'https://www.kimai.org/documentation/users.html' 'help' => 'https://www.kimai.org/documentation/users.html'
]); ]);
} }

View File

@@ -10,7 +10,6 @@
namespace App\Tests\DependencyInjection; namespace App\Tests\DependencyInjection;
use App\DependencyInjection\AppExtension; use App\DependencyInjection\AppExtension;
use PHPUnit\Framework\Error\Notice;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -301,7 +300,7 @@ class AppExtensionTest extends TestCase
*/ */
public function testDurationOnlyDeprecationIsTriggered() public function testDurationOnlyDeprecationIsTriggered()
{ {
$this->expectException(Notice::class); $this->expectNotice();
$this->expectExceptionMessage('Found ambiguous configuration: remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); $this->expectExceptionMessage('Found ambiguous configuration: remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.');
$minConfig = $this->getMinConfig(); $minConfig = $this->getMinConfig();
@@ -449,7 +448,7 @@ class AppExtensionTest extends TestCase
public function testWithBundleConfigurationFailsOnDuplicatedKey() public function testWithBundleConfigurationFailsOnDuplicatedKey()
{ {
$this->expectException(Notice::class); $this->expectNotice();
$this->expectExceptionMessage('Invalid bundle configuration "timesheet" found, skipping'); $this->expectExceptionMessage('Invalid bundle configuration "timesheet" found, skipping');
$bundleConfig = [ $bundleConfig = [
@@ -463,7 +462,7 @@ class AppExtensionTest extends TestCase
public function testWithBundleConfigurationFailsOnNonArray() public function testWithBundleConfigurationFailsOnNonArray()
{ {
$this->expectException(Notice::class); $this->expectNotice();
$this->expectExceptionMessage('Invalid bundle configuration found, skipping all bundle configuration'); $this->expectExceptionMessage('Invalid bundle configuration found, skipping all bundle configuration');
$container = $this->getContainer(); $container = $this->getContainer();

View File

@@ -163,7 +163,18 @@ abstract class AbstractRendererTest extends KernelTestCase
->setMetaField((new TimesheetMeta())->setName('foo2')->setValue('meta-bar2')->setIsVisible(true)) ->setMetaField((new TimesheetMeta())->setName('foo2')->setValue('meta-bar2')->setIsVisible(true))
; ;
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5]; $timesheet6 = new Timesheet();
$timesheet6
->setDuration(400)
->setFixedRate(-100.92)
->setUser((new User())->setUsername('nivek'))
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime('2019-06-16 12:00:00'))
->setEnd(new \DateTime('2019-06-16 12:06:40'))
;
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6];
$query = new TimesheetQuery(); $query = new TimesheetQuery();
$query->setActivities([$activity]); $query->setActivities([$activity]);

View File

@@ -34,7 +34,7 @@ class CsvRendererTest extends AbstractRendererTest
public function getTestModel() public function getTestModel()
{ {
return [ return [
['400', '2437.12', ' EUR 1,947.99 ', 6, 5, 1, 2, 2] ['400', '2437.12', ' EUR 1,947.99 ', 7, 6, 1, 2, 2]
]; ];
} }
@@ -107,8 +107,40 @@ class CsvRendererTest extends AbstractRendererTest
27 => 'ORDER-123', 27 => 'ORDER-123',
]; ];
self::assertEquals(6, \count($all)); $expected2 = [
0 => '2019-06-16',
1 => '12:00',
2 => '12:06',
3 => '400',
4 => '0',
5 => '',
6 => 'nivek',
7 => 'nivek',
8 => 'Customer Name',
9 => 'project name',
10 => 'activity description',
11 => '',
12 => '',
13 => '',
14 => '',
15 => '',
16 => ' EUR -100.92',
17 => '',
18 => '',
19 => 'customer-bar',
20 => '',
21 => 'project-foo2',
22 => 'activity-bar',
23 => 'timesheet',
24 => 'work',
25 => 'A-0123456789',
26 => 'DE-9876543210',
27 => 'ORDER-123',
];
self::assertEquals(7, \count($all));
self::assertEquals($expected, $all[5]); self::assertEquals($expected, $all[5]);
self::assertEquals($expected2, $all[6]);
self::assertEquals(\count($expected), \count($all[0])); self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][14]); self::assertEquals('foo', $all[4][14]);
} }

View File

@@ -82,11 +82,12 @@ class HtmlRendererTest extends AbstractRendererTest
$this->assertStringContainsString('<td>Customer Name</td>', $content); $this->assertStringContainsString('<td>Customer Name</td>', $content);
$this->assertStringContainsString('<td>project name</td>', $content); $this->assertStringContainsString('<td>project name</td>', $content);
$this->assertStringContainsString('<span class="duration-format" data-duration="6600">01:50 h</span>', $content); $this->assertStringContainsString('<span class="duration-format" data-duration="7000">01:56 h</span>', $content);
$this->assertStringContainsString('<td class="cost summary-rate">€2,437.12</td>', $content); $this->assertStringContainsString('<td class="cost summary-rate">€2,437.12</td>', $content);
$this->assertStringContainsString('-€100.92', $content);
// 5 times in the "full list" and once in the "summary with activities" // 5 times in the "full list" and once in the "summary with activities"
$this->assertEquals(6, substr_count($content, 'activity description')); $this->assertEquals(7, substr_count($content, 'activity description'));
$this->assertEquals(1, substr_count($content, '<td>activity description</td>')); $this->assertEquals(1, substr_count($content, '<td>activity description</td>'));
} }
} }

View File

@@ -26,7 +26,7 @@ class MarkdownExtensionTest extends TestCase
$config = new SystemConfiguration($loader, ['timesheet' => ['markdown_content' => true]]); $config = new SystemConfiguration($loader, ['timesheet' => ['markdown_content' => true]]);
$sut = new MarkdownExtension(new Markdown(), $config); $sut = new MarkdownExtension(new Markdown(), $config);
$this->assertEquals('<p><em>test</em></p>', $sut->markdownToHtml('*test*')); $this->assertEquals('<p><em>test</em></p>', $sut->markdownToHtml('*test*'));
$this->assertEquals('<p># foobar</p>', $sut->markdownToHtml('# foobar')); $this->assertEquals('<h1>foobar</h1>', $sut->markdownToHtml('# foobar'));
$this->assertEquals( $this->assertEquals(
'<p><a href="javascript%3Aalert(`XSS`)">XSS</a></p>', '<p><a href="javascript%3Aalert(`XSS`)">XSS</a></p>',
$sut->markdownToHtml('[XSS](javascript:alert(`XSS`))') $sut->markdownToHtml('[XSS](javascript:alert(`XSS`))')
@@ -44,6 +44,9 @@ class MarkdownExtensionTest extends TestCase
); );
$this->assertEquals('', $sut->timesheetContent(null)); $this->assertEquals('', $sut->timesheetContent(null));
$this->assertEquals('', $sut->timesheetContent('')); $this->assertEquals('', $sut->timesheetContent(''));
$this->assertEquals('# foobar', $sut->timesheetContent('# foobar'));
$this->assertEquals('## foobar', $sut->timesheetContent('## foobar'));
$this->assertEquals('### foobar', $sut->timesheetContent('### foobar'));
$config = new SystemConfiguration($loader, ['timesheet' => ['markdown_content' => true]]); $config = new SystemConfiguration($loader, ['timesheet' => ['markdown_content' => true]]);
$sut = new MarkdownExtension(new Markdown(), $config); $sut = new MarkdownExtension(new Markdown(), $config);
@@ -75,6 +78,9 @@ class MarkdownExtensionTest extends TestCase
$this->assertEquals('', $sut->commentContent(null)); $this->assertEquals('', $sut->commentContent(null));
$this->assertEquals('', $sut->commentContent('')); $this->assertEquals('', $sut->commentContent(''));
$this->assertEquals('# foobar', $sut->commentContent('# foobar'));
$this->assertEquals('## foobar', $sut->commentContent('## foobar'));
$this->assertEquals('### foobar', $sut->commentContent('### foobar'));
$this->assertEquals('<p>' . $loremIpsum . '</p>', $sut->commentContent($loremIpsum, true)); $this->assertEquals('<p>' . $loremIpsum . '</p>', $sut->commentContent($loremIpsum, true));
$this->assertEquals('Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l &hellip;', $sut->commentContent($loremIpsum)); $this->assertEquals('Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l &hellip;', $sut->commentContent($loremIpsum));

View File

@@ -13,7 +13,11 @@ use Symfony\Component\Dotenv\Dotenv;
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
(new Dotenv(false))->loadEnv(dirname(__DIR__) . '/.env'); (new Dotenv(false))->loadEnv(dirname(__DIR__) . '/.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$env = $_SERVER['APP_ENV'] ?? 'prod';
$debug = (bool) ($_SERVER['APP_DEBUG'] ?? (in_array($env, ['dev', 'test'])));
$kernel = new Kernel($env, $debug);
$kernel->boot(); $kernel->boot();
return $kernel->getContainer()->get('doctrine')->getManager(); return $kernel->getContainer()->get('doctrine')->getManager();

View File

@@ -2,7 +2,7 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2"> <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="he" datatype="plaintext" original="export.en.xlf"> <file source-language="en" target-language="he" datatype="plaintext" original="export.en.xlf">
<body> <body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig"> <trans-unit id="IbERy.5" resname="default.pdf.twig">
<source>Default</source> <source>Default</source>
<target>ברירת מחדל</target> <target>ברירת מחדל</target>
</trans-unit> </trans-unit>