Release 2.46 (#5757)

This commit is contained in:
Kevin Papst
2026-01-07 00:59:47 +01:00
committed by GitHub
parent 9e87fc131b
commit 6a86afb5fd
55 changed files with 1582 additions and 1195 deletions

2
.gitattributes vendored
View File

@@ -5,8 +5,10 @@ tests export-ignore
.codecov.yml export-ignore
.editorconfig export-ignore
eslint.config.js export-ignore
eslint.config.mjs export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.php-cs-fixer.dist.php export-ignore
php-cs-fixer.dist.php export-ignore
babel.config.js export-ignore
package.json export-ignore

3
.gitignore vendored
View File

@@ -3,6 +3,8 @@
/.env-*
/.idea/
.DS_Store
var/templates/
# custom apache rules e.g. to deactivate ioncube loader
/public/.user.ini
@@ -14,6 +16,7 @@
# YARN 2
/.yarnrc.yml
/.yarn
/.pnp.*
# for keeping empty directories
/config/packages/local.yaml

View File

@@ -10,7 +10,7 @@ Send your ideas, code reviews, pull requests and feature requests to help to imp
- Make your changes in a new git branch, based on the latest code in `main`
- Apply our code-style by running `composer codestyle-fix`
- Run the static code analysis with `composer phpstan`
- Verify everything still works with `composer tests-unit`
- Verify everything still works with `composer tests`
- Add tests for your changes
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -24,7 +24,7 @@ export default class KimaiAutocompleteTags extends KimaiAutocomplete {
API.get(apiUrl, {'name': query}, (data) => {
let results = [];
for (let item of data) {
results.push({text: item.name, value: item.name, color: item.color});
results.push({text: item.name, value: item.name, color: item['color-safe']});
}
callback(results);
}, () => {

397
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
<tile>
<square150x150logo src="favicon/mstile-150x150.png"/>
<square310x310logo src="favicon/mstile-large.jpg"/>
<TileColor>#00a300</TileColor>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{876:function(i,n,u){"use strict";u.r(n)},2395:function(i,n,u){u(876)}},function(i){var n;n=2395,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{177:function(i,n,u){u(7996)},7996:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=177,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.9662939e.js"
"/build/app.307ea672.js"
],
"css": [
"/build/app.c18ba3c6.css"
@@ -12,7 +12,7 @@
"app-rtl": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app-rtl.15853b82.js"
"/build/app-rtl.fe7c5bf2.js"
],
"css": [
"/build/app-rtl.2003dce5.css"
@@ -21,7 +21,7 @@
"export-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/export-pdf.395749ab.js"
"/build/export-pdf.5905454a.js"
],
"css": [
"/build/export-pdf.d8a6c23b.css"
@@ -30,7 +30,7 @@
"invoice": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice.42b319e4.js"
"/build/invoice.0217cc18.js"
],
"css": [
"/build/invoice.36018785.css"
@@ -39,7 +39,7 @@
"invoice-pdf": {
"js": [
"/build/runtime.6c399d29.js",
"/build/invoice-pdf.26d98626.js"
"/build/invoice-pdf.1b6e735c.js"
],
"css": [
"/build/invoice-pdf.2b749265.css"
@@ -48,13 +48,13 @@
"chart": {
"js": [
"/build/runtime.6c399d29.js",
"/build/chart.bafa38e7.js"
"/build/chart.2c85f027.js"
]
},
"calendar": {
"js": [
"/build/runtime.6c399d29.js",
"/build/calendar.b2d70caa.js"
"/build/calendar.cef61816.js"
],
"css": [
"/build/calendar.d757753e.css"
@@ -63,7 +63,7 @@
"dashboard": {
"js": [
"/build/runtime.6c399d29.js",
"/build/dashboard.6ce7ac9c.js"
"/build/dashboard.faf42d4e.js"
],
"css": [
"/build/dashboard.b7129fa1.css"
@@ -72,7 +72,7 @@
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.13d5d50e.js"
"/build/highlight.718fe73d.js"
],
"css": [
"/build/highlight.98bf3927.css"
@@ -81,22 +81,22 @@
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.9662939e.js": "sha384-bRL78SVI1wjTweqKeb+ZSSbA78i6By89S8/xx+55zmbOVtF1TCu3lf3pvdki5ZVJ",
"/build/app.307ea672.js": "sha384-XNjtkar2JbWyJQan/C6jHZAYFI9X5rPcExLeDV86FYZMPJl0vNWMh8Pa8NgXX/EB",
"/build/app.c18ba3c6.css": "sha384-qkIgqLzngG2NchdFVImbGMU49lWlZg6Y9D0z2P0u1j2NQstDicA4KqIkkNYcpErm",
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
"/build/app-rtl.fe7c5bf2.js": "sha384-UyLXlR/3/H6oFjRrIMH04PA8bAjVQ8rdVWkWoiO4LLYasg4H3UNWJ+Xh+FrYekKh",
"/build/app-rtl.2003dce5.css": "sha384-pl8GyGo8sRRw1zLh9D42ZAvo450onfxeszZWocUUuzq70jAOwOlaoPYh/yyc20U7",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.5905454a.js": "sha384-AkNj018YEQJSxC1e/3jIvlh0uR5tHnlUe9ciBbHKIoRIwj7658tCLNVaT98SNFEX",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
"/build/invoice.0217cc18.js": "sha384-ORySQp4DBSYc7so90AAb8h5abgQtz3tdoO+Zv1uHJ2XqSPMAGtFBrb+Rdzc6sZg1",
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.1b6e735c.js": "sha384-8I5YMkETBNl7wD12V/hKX2YGfwH+V6PXWsu4I+1xXXx9bDzqDhtq+S7x/jey2/Wr",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.bafa38e7.js": "sha384-Ays2qGKvOqs4NSeN/zJOPcrnzIEC/uYSCIkC3kN0KQye+mA5Lq7UkfjZDjulvFUX",
"/build/calendar.b2d70caa.js": "sha384-FS7Q9iCWHpo2Nzk0tmNvnuTbxJKLdbXFba9F6WcXfHbKFAS2Q+lW29H9Mv+ap5kA",
"/build/chart.2c85f027.js": "sha384-5Dm9aFCCSzlh5QY/vtU7/UrBkKt6aWnTccjljmpbQVFfzSEfFh0wUUV8qlXqyFJj",
"/build/calendar.cef61816.js": "sha384-qCpJrSB6fZLXT94oiWHofGbX8C0TFl86oCMTdeKWqCmNGJxABkIVo+8k19p7sGlY",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.6ce7ac9c.js": "sha384-5BOoyjZx/ZKr9IbogWfT/aBRHx7XA1fePADdUpUNlSF/rqVo3taXLLcrIoaDZ+8b",
"/build/dashboard.faf42d4e.js": "sha384-xkM5D8wBK1sJekLI9DUQBxTjxjomfPQWWtWCo7NX03jNnFZtMv5anM0IISLWaFY0",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.13d5d50e.js": "sha384-+RqBBPIzp5YQpbBeHgVNXzHiewK1PZtizPh4in1PlBU6RcT1bDleW3QSJMDeBnPO",
"/build/highlight.718fe73d.js": "sha384-eHb21blXJ7SKqCFlMSxl4oblpusDif1B0Tsr2Iw1xQfUqxZxwtFwYYvMPllfw7BY",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
}
}

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{878:function(i,n,u){u(3385)},3385:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=878,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{585:function(i,n,u){"use strict";u.r(n)},2352:function(i,n,u){u(585)}},function(i){var n;n=2352,i(i.s=n)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{2049:function(i,n,u){u(4860)},4860:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2049,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{1555:function(i,n,u){u(6620)},6620:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=1555,i(i.s=n)}]);

View File

@@ -0,0 +1 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{239:function(i,n,u){"use strict";u.r(n)},8478:function(i,n,u){u(239)}},function(i){var n;n=8478,i(i.s=n)}]);

View File

@@ -1 +0,0 @@
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{3631:function(i,n,u){"use strict";u.r(n)},4820:function(i,n,u){u(3631)}},function(i){var n;n=4820,i(i.s=n)}]);

View File

@@ -1,21 +1,21 @@
{
"build/app.css": "/build/app.c18ba3c6.css",
"build/app.js": "/build/app.9662939e.js",
"build/app.js": "/build/app.307ea672.js",
"build/app-rtl.css": "/build/app-rtl.2003dce5.css",
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
"build/app-rtl.js": "/build/app-rtl.fe7c5bf2.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
"build/export-pdf.js": "/build/export-pdf.5905454a.js",
"build/invoice.css": "/build/invoice.36018785.css",
"build/invoice.js": "/build/invoice.42b319e4.js",
"build/invoice.js": "/build/invoice.0217cc18.js",
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.bafa38e7.js",
"build/invoice-pdf.js": "/build/invoice-pdf.1b6e735c.js",
"build/chart.js": "/build/chart.2c85f027.js",
"build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.b2d70caa.js",
"build/calendar.js": "/build/calendar.cef61816.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.6ce7ac9c.js",
"build/dashboard.js": "/build/dashboard.faf42d4e.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.13d5d50e.js",
"build/highlight.js": "/build/highlight.718fe73d.js",
"build/runtime.js": "/build/runtime.6c399d29.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",

View File

@@ -25,7 +25,7 @@
],
"scope": "./",
"start_url": "./",
"theme-color": "#1d273b",
"theme-color": "#262626",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.45.0';
public const VERSION = '2.46.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24500;
public const VERSION_ID = 24600;
/**
* The software name
*/

View File

@@ -23,7 +23,8 @@ trait ColorTrait
* The assigned color in HTML hex format, e.g. #dd1d00
*/
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
#[Serializer\Exclude]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'color')]
#[Constraints\HexColor]
private ?string $color = null;
@@ -50,10 +51,10 @@ trait ColorTrait
abstract public function getName(): ?string;
/**
* Internal value: this color will never be empty and is generated by the tag name if not set explicit.
* Color will never be empty and is generated from the entity tag name if not set explicit.
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('color')]
#[Serializer\SerializedName('color-safe')]
#[Serializer\Groups(['Default'])]
public function getColorSafe(): string
{

View File

@@ -117,7 +117,11 @@ class HtmlRenderer implements ExportRendererInterface
'userPreferences' => $userPreferences,
], $this->getOptions($query)));
// allows to run in development mode, otherwise toolbar would be blocked
$sandbox->disableSandbox();
$response = new Response();
$response->headers->set('Content-Type', 'text/html');
$response->setContent($content);
return $response;

View File

@@ -16,10 +16,6 @@ final class ChainPolicy implements SecurityPolicyInterface
/** @var array<SecurityPolicyInterface> */
private array $policies = [];
public function __construct()
{
}
public function addPolicy(SecurityPolicyInterface $policy): void
{
$this->policies[] = $policy;

View File

@@ -9,6 +9,13 @@
namespace App\Twig\SecurityPolicy;
use App\Entity\User;
use App\Pdf\PdfContext;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityPolicyInterface;
/**
@@ -22,6 +29,49 @@ final class DefaultPolicy implements SecurityPolicyInterface
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof ServerBag) {
throw new SecurityNotAllowedMethodError('Tried to access server environment', ServerBag::class, $method);
}
if ($obj instanceof SessionInterface) {
throw new SecurityNotAllowedMethodError('Tried to access session', SessionInterface::class, $method);
}
$lcm = strtolower($method);
if ($obj instanceof PdfContext) {
if ($lcm !== 'setoption') {
throw new SecurityNotAllowedMethodError('Tried to access forbidden method on PdfContext', PdfContext::class, $method);
}
return;
}
if (!str_starts_with($lcm, 'has') && !str_starts_with($lcm, 'is') && !str_starts_with($lcm, 'get') && $lcm !== '__tostring') {
throw new SecurityNotAllowedMethodError('Tried to access non-read method', $obj::class, $method);
}
if ($obj instanceof Request) {
if (!str_starts_with($lcm, 'get')) {
throw new SecurityNotAllowedMethodError('Tried to call setter() of app variable', AppVariable::class, $method);
}
return;
}
if ($obj instanceof AppVariable) {
if (!\in_array($lcm, ['getrequest', 'getuser', 'getlocale'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access forbidden app variable method', User::class, $method);
}
return;
}
if ($obj instanceof User) {
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
}
}
}
public function checkPropertyAllowed($obj, $property): void

View File

@@ -1,109 +0,0 @@
<?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\Twig\SecurityPolicy;
use Twig\Markup;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;
/**
* A blocking approach for Twig templates.
*/
final class ForbiddenPolicy implements SecurityPolicyInterface
{
/** @var array<string, array<string>> */
private array $forbiddenMethods = [];
/**
* @param array<string> $forbiddenTags
* @param array<string> $forbiddenFilters
* @param array<string, array<string>> $forbiddenMethods
* @param array<string, array<string>> $forbiddenProperties
* @param array<string> $forbiddenFunctions
*/
public function __construct(
private readonly array $forbiddenTags = [],
private readonly array $forbiddenFilters = [],
array $forbiddenMethods = [],
private readonly array $forbiddenProperties = [],
private readonly array $forbiddenFunctions = []
)
{
$this->forbiddenMethods = [];
foreach ($forbiddenMethods as $class => $m) {
$this->forbiddenMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]);
}
}
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($tags as $tag) {
if (\in_array($tag, $this->forbiddenTags)) {
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
}
}
foreach ($filters as $filter) {
if (\in_array($filter, $this->forbiddenFilters)) {
throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter);
}
}
foreach ($functions as $function) {
if (\in_array($function, $this->forbiddenFunctions)) {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
}
}
}
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Template || $obj instanceof Markup) { // @phpstan-ignore instanceof.internalClass
return;
}
$forbidden = false;
$method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
foreach ($this->forbiddenMethods as $class => $methods) {
if ($obj instanceof $class) {
$forbidden = \in_array($method, $methods);
break;
}
}
if ($forbidden) {
$class = \get_class($obj);
throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method);
}
}
public function checkPropertyAllowed($obj, $property): void
{
$forbidden = false;
foreach ($this->forbiddenProperties as $class => $properties) {
if ($obj instanceof $class) {
$forbidden = \in_array($property, \is_array($properties) ? $properties : [$properties]);
break;
}
}
if ($forbidden) {
$class = \get_class($obj);
throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property);
}
}
}

View File

@@ -22,13 +22,13 @@ use Twig\Template;
*/
final class InvoicePolicy implements SecurityPolicyInterface
{
private ChainPolicy $policy;
private SecurityPolicyInterface $default;
private SecurityPolicyInterface $security;
public function __construct()
{
$this->policy = new ChainPolicy();
$this->policy->addPolicy(new DefaultPolicy());
$this->policy->addPolicy(new SecurityPolicy(
$this->default = new DefaultPolicy();
$this->security = new SecurityPolicy(
['block', 'if', 'for', 'set', 'extends', 'import'],
[
// =================================================================
@@ -194,12 +194,13 @@ final class InvoicePolicy implements SecurityPolicyInterface
'month_names',
'locale_format',
]
));
);
}
public function checkSecurity($tags, $filters, $functions): void
{
$this->policy->checkSecurity($tags, $filters, $functions);
$this->default->checkSecurity($tags, $filters, $functions);
$this->security->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method): void
@@ -208,21 +209,20 @@ final class InvoicePolicy implements SecurityPolicyInterface
return;
}
$this->default->checkMethodAllowed($obj, $method);
$lm = strtolower($method);
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has')) {
if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has') || $lm === '__tostring') {
return;
}
if ($lm === '__tostring') {
return;
}
$this->policy->checkMethodAllowed($obj, $method);
$this->security->checkMethodAllowed($obj, $method);
}
public function checkPropertyAllowed($obj, $property): void
{
$this->policy->checkPropertyAllowed($obj, $property);
$this->default->checkPropertyAllowed($obj, $property);
$this->security->checkPropertyAllowed($obj, $property);
}
}

View File

@@ -1,7 +1,6 @@
{% extends 'export/layout.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/webloader.html.twig" as webloader %}
{% block document_title %}{{ 'export'|trans }}{% endblock %}
@@ -52,147 +51,144 @@
}) %}
{% block javascripts %}
{{ webloader.init_frontend_loader() }}
<script type="text/javascript">
let initialized = false;
document.addEventListener('kimai.initialized', function(event) {
document.getElementById('duration-decimal').addEventListener('click', function(event) {
var spans = document.getElementsByClassName('duration-format');
for (var span of spans) {
if (!event.target.checked) {
if (span.dataset['duration'] !== undefined) {
span.innerHTML = span.dataset['duration'];
}
} else {
if (span.dataset['durationDecimal'] !== undefined) {
span.innerHTML = span.dataset['durationDecimal'];
}
document.getElementById('duration-decimal').addEventListener('click', function(event) {
var spans = document.getElementsByClassName('duration-format');
for (var span of spans) {
if (!event.target.checked) {
if (span.dataset['duration'] !== undefined) {
span.innerHTML = span.dataset['duration'];
}
} else {
if (span.dataset['durationDecimal'] !== undefined) {
span.innerHTML = span.dataset['durationDecimal'];
}
}
saveVisibility();
});
document.getElementById('summary-by-activities').addEventListener('click', function(event) {
document.getElementById('summary-project').style.display = event.target.checked ? 'none' : 'table';
document.getElementById('summary-activity').style.display = event.target.checked ? 'table' : 'none';
saveVisibility();
});
document.getElementById('date-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'dateformat');
});
document.getElementById('begin-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'beginformat');
});
document.getElementById('end-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'endformat');
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('summary-timeBudget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-timeBudget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-budget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-budget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-duration').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-duration');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-rate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-rate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-internalRate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-internalRate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('records-show').addEventListener('click', function(event) {
document.getElementById('export-records').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
let columnCheckboxes = document.getElementsByClassName('column-visibility-changer');
for (let checkbox of columnCheckboxes) {
checkbox.addEventListener('click', function(event) {
changeVisibility(event.target.name, event.target.checked);
});
}
saveVisibility();
});
let editableTitles = document.querySelectorAll('[contenteditable=true]');
document.getElementById('summary-by-activities').addEventListener('click', function(event) {
document.getElementById('summary-project').style.display = event.target.checked ? 'none' : 'table';
document.getElementById('summary-activity').style.display = event.target.checked ? 'table' : 'none';
saveVisibility();
});
document.getElementById('date-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'dateformat');
});
document.getElementById('begin-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'beginformat');
});
document.getElementById('end-format').addEventListener('change', function(event) {
changedDateFormat(event.target.value, 'endformat');
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('summary-timeBudget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-timeBudget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-budget').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('export-budget');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-duration').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-duration');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-rate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-rate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-internalRate').addEventListener('click', function(event) {
let cells = document.getElementsByClassName('summary-internalRate');
for (let columnCell of cells) {
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
}
saveVisibility();
});
document.getElementById('summary-show').addEventListener('click', function(event) {
document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
document.getElementById('records-show').addEventListener('click', function(event) {
document.getElementById('export-records').style.display = event.target.checked ? 'block' : 'none';
saveVisibility();
});
for (let editable of editableTitles) {
editable.addEventListener('input', function(event) {
if (event.target.innerText === '') {
return;
let columnCheckboxes = document.getElementsByClassName('column-visibility-changer');
for (let checkbox of columnCheckboxes) {
checkbox.addEventListener('click', function(event) {
changeVisibility(event.target.name, event.target.checked);
});
}
let editableTitles = document.querySelectorAll('[contenteditable=true]');
for (let editable of editableTitles) {
editable.addEventListener('input', function(event) {
if (event.target.innerText === '') {
return;
}
saveVisibility();
});
}
// needs to be executed as last action in the flow, after the listener were registered
let config = localStorage.getItem('{{ storageItemName }}');
if (config !== null) {
try {
config = JSON.parse(config);
for (const elName in config) {
if (!config.hasOwnProperty(elName)) {
continue;
}
saveVisibility();
});
}
// needs to be executed as last action in the flow, after the listener were registered
let config = localStorage.getItem('{{ storageItemName }}');
if (config !== null) {
try {
config = JSON.parse(config);
for (const elName in config) {
if (!config.hasOwnProperty(elName)) {
let elValue = config[elName];
let el = document.getElementById(elName);
if (el === undefined || el === null) {
el = document.getElementById('records-column-' + elName);
if (el === undefined || el === null) {
continue;
}
let elValue = config[elName];
let el = document.getElementById(elName);
if (el === undefined || el === null) {
el = document.getElementById('records-column-' + elName);
if (el === undefined || el === null) {
continue;
}
}
if (el.type === 'checkbox') {
if (elValue !== el.checked) {
el.click();
}
} else if (el.type === 'select-one') {
if (el.value !== elValue) {
el.value = elValue;
el.dispatchEvent(new Event('change'));
}
} else if (el.isContentEditable) {
el.innerText = elValue;
}
}
} catch (e) {
// ignore error in restoring
console.log('Failed to restore settings, removing invalid settings', e);
localStorage.removeItem('{{ storageItemName }}');
if (el.type === 'checkbox') {
if (elValue !== el.checked) {
el.click();
}
} else if (el.type === 'select-one') {
if (el.value !== elValue) {
el.value = elValue;
el.dispatchEvent(new Event('change'));
}
} else if (el.isContentEditable) {
el.innerText = elValue;
}
}
} catch (e) {
// ignore error in restoring
console.log('Failed to restore settings, removing invalid settings', e);
localStorage.removeItem('{{ storageItemName }}');
}
}
initialized = true;
});
initialized = true;
function changeVisibility(column, visible)
{

View File

@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="{{ constant('App\\Constants::SOFTWARE') }}">
<meta name="msapplication-config" content="{{ asset('browserconfig.xml') }}">
<meta name="theme-color" content="#1d273b">
<meta name="theme-color" content="#262626">
{% if tabler_bundle.isThemeAuto() %}
<script>if (window.matchMedia) { document.documentElement.setAttribute('data-bs-theme', (window.matchMedia("(prefers-color-scheme: dark)").matches) ? 'dark': 'light'); }</script>
{% endif %}

View File

@@ -327,7 +327,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [
'id' => 'int',
'name' => 'string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'visible' => 'bool',
];
@@ -358,7 +359,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'enabled' => 'bool',
'apiToken' => 'bool',
'systemAccount' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'avatar' => '@string',
'alias' => '@string',
'accountNumber' => '@string',
@@ -378,7 +380,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'enabled' => 'bool',
'apiToken' => 'bool',
'systemAccount' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'avatar' => '@string',
'alias' => '@string',
'accountNumber' => '@string',
@@ -402,7 +405,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [
'id' => 'int',
'name' => 'string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
];
// explicitly requested team
@@ -410,7 +414,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
return [
'id' => 'int',
'name' => 'string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'members' => ['result' => 'array', 'type' => 'TeamMember'],
// TODO more info in entity than in collection
'customers' => ['result' => 'array', 'type' => '@Customer'],
@@ -439,7 +444,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'number' => '@string',
'comment' => '@string',
'currency' => 'string',
@@ -460,7 +466,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'boolean',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'number' => '@string',
'comment' => '@string',
'currency' => 'string',
@@ -482,7 +489,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'number' => '@string',
'comment' => '@string',
'currency' => 'string',
@@ -519,7 +527,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => 'int',
'number' => '@string',
'orderNumber' => '@string',
@@ -538,7 +547,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@string',
'orderNumber' => '@string',
@@ -557,7 +567,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => 'int',
'number' => '@string',
'orderNumber' => '@string',
@@ -578,7 +589,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'name' => 'string',
'visible' => 'bool',
'billable' => 'bool',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'customer' => 'int',
'number' => '@string',
'orderNumber' => '@string',
@@ -605,7 +617,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => '@int',
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'comment' => '@string',
];
@@ -618,7 +631,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], // since 2.45
'comment' => '@string',
];
@@ -632,7 +646,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => '@int',
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'comment' => '@string',
'parentTitle' => '@string',
@@ -648,7 +663,8 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'project' => '@int',
'number' => '@string',
'color' => 'string',
'color' => '@string',
'color-safe' => 'string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'comment' => '@string',
'parentTitle' => '@string',

View File

@@ -244,7 +244,6 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ActivityEntity', $result);
self::assertCount(14, array_keys($result));
self::assertNull($result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
@@ -258,7 +257,8 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertNull($result['budgetType']);
self::assertNull($result['number']);
self::assertEquals('Test comment', $result['comment']);
self::assertEquals('#5319e7', $result['color']);
self::assertNull($result['color']);
self::assertEquals('#5319e7', $result['color-safe']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
@@ -297,7 +297,7 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null, 'color-safe' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);

View File

@@ -229,7 +229,6 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertCount(30, array_keys($result));
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertCount(1, $result['teams']);
@@ -262,7 +261,8 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertNull($result['homepage']);
self::assertEquals('Europe/Berlin', $result['timezone']);
self::assertNull($result['buyerReference']);
self::assertEquals('#5319e7', $result['color']);
self::assertNull($result['color']);
self::assertEquals('#5319e7', $result['color-safe']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
@@ -316,7 +316,7 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null, 'color-safe' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);

View File

@@ -311,7 +311,6 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ProjectEntity', $result);
self::assertCount(19, array_keys($result));
self::assertEquals('first one', $result['parentTitle']);
self::assertEquals($project->getId(), $result['id']);
self::assertIsArray($result['teams']);
@@ -329,7 +328,8 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertNull($result['orderNumber']);
self::assertNull($result['number']);
self::assertNull($result['comment']);
self::assertEquals('#2ECC40', $result['color']);
self::assertNull($result['color']);
self::assertEquals('#2ECC40', $result['color-safe']);
self::assertTrue($result['globalActivities']);
self::assertTrue($result['billable']);
self::assertTrue($result['visible']);
@@ -374,7 +374,7 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => '#03A9F4']], $result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null, 'color-safe' => '#03A9F4']], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);

View File

@@ -0,0 +1,128 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export\Base;
use App\Export\Base\CsvRenderer;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\PDFRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ServiceExport;
use App\Repository\ExportTemplateRepository;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Psr\Log\LoggerInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[CoversClass(ServiceExport::class)]
#[CoversClass(CsvRenderer::class)]
#[CoversClass(XlsxRenderer::class)]
#[CoversClass(PDFRenderer::class)]
#[CoversClass(HtmlRenderer::class)]
#[Group('integration')]
class DefaultRendererTest extends AbstractRendererTestCase
{
private function createServiceExport(): ServiceExport
{
$repository = $this->createMock(ExportTemplateRepository::class);
$repository->expects($this->once())->method('findAll')->willReturn([]);
$logger = $this->createMock(LoggerInterface::class);
return new ServiceExport(
$this->createMock(EventDispatcherInterface::class),
(new HtmlRendererFactoryMock($this))->create(),
(new PdfRendererFactoryMock($this))->create(),
(new CsvRendererFactoryMock($this))->create(),
(new XlsxRendererFactoryMock($this))->create(),
$repository,
$logger,
);
}
public function testRenderDefaultTemplates(): void
{
$sut = $this->createServiceExport();
$renderer = $sut->getRenderer();
self::assertCount(4, $renderer);
self::assertInstanceOf(CsvRenderer::class, $renderer[0]);
self::assertInstanceOf(XlsxRenderer::class, $renderer[1]);
self::assertInstanceOf(PDFRenderer::class, $renderer[2]);
self::assertInstanceOf(HtmlRenderer::class, $renderer[3]);
// make sure that the default templates do NOT violate the Twig SecurityPolicy
$response = $this->render($renderer[0]);
self::assertEquals('text/csv', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition') ?? '');
$response = $this->render($renderer[1]);
self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type') ?? '');
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition') ?? '');
$response = $this->render($renderer[2]);
self::assertEquals('application/pdf', $response->headers->get('Content-Type') ?? '');
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition') ?? '');
$response = $this->render($renderer[3]);
self::assertEquals('text/html', $response->headers->get('Content-Type') ?? '');
// HTML is attached to the body and twig is a mock in this setupo, so we just receive an empty string
}
public function testRenderCustomTemplates(): void
{
$searchDir = __DIR__ . '/../../../var/templates';
if (!is_dir($searchDir)) {
$this->expectNotToPerformAssertions();
return;
}
$finder = new Finder();
$finder
->in($searchDir)
->name('*.twig')
->path('export-tpl/')
->files()
;
$files = [];
$dirs = [];
foreach ($finder->getIterator() as $filename => $splFile) {
$files[] = $splFile->getRealPath();
$dir = \dirname($splFile->getRealPath());
$dirs[$dir] = $dir;
}
$dirs = array_keys($dirs);
if (\count($dirs) === 0) {
$this->expectNotToPerformAssertions();
return;
}
$sut = $this->createServiceExport();
foreach ($dirs as $dir) {
$sut->addDirectory($dir);
}
$renderers = $sut->getRenderer();
self::assertCount(4 + \count($files), $renderers);
foreach ($renderers as $renderer) {
$response = $this->render($renderer);
self::assertInstanceOf(Response::class, $response);
}
}
}

View File

@@ -129,8 +129,8 @@ abstract class AbstractRendererTestCase extends KernelTestCase
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6];
$currentUser = $this->createMock(User::class);
$currentUser->expects($this->any())->method('isExportDecimal')->willReturn($exportDecimal);
$currentUser = new User();
$currentUser->setPreferenceValue('export_decimal', $exportDecimal);
$query = new TimesheetQuery();
$query->setActivities([$activity]);

View File

@@ -14,10 +14,12 @@ use App\Invoice\InvoiceItemHydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use App\Model\InvoiceDocument;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
#[CoversClass(DebugRenderer::class)]
class DebugRendererTest extends TestCase
{
use RendererTestTrait;

View File

@@ -18,6 +18,7 @@ use App\Tests\Mocks\FileHelperFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
@@ -118,12 +119,26 @@ class PdfRendererTest extends KernelTestCase
$dirs = [
__DIR__ . '/../../../templates/invoice/renderer/',
//__DIR__ . '/../../../var/invoices/',
//__DIR__ . '/../../../var/invoices_customer/',
//__DIR__ . '/../../../var/invoices_old/',
];
$files = [];
$additionalTemplatesDir = __DIR__ . '/../../../var/templates';
if (is_dir($additionalTemplatesDir)) {
$finder = new Finder();
$finder
->in($additionalTemplatesDir)
->name('*.pdf.twig')
->path('invoice-tpl/')
->files();
foreach ($finder->getIterator() as $splFile) {
$filename = $splFile->getRealPath();
$files[] = $filename;
$loader->addPath(\dirname($filename) . '/', 'invoice');
}
}
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
continue;
@@ -145,7 +160,6 @@ class PdfRendererTest extends KernelTestCase
$response = $sut->render($document, $model);
self::assertEquals('application/pdf', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition'));
self::assertNotEmpty($response->getContent());
}
}
}

View File

@@ -32,7 +32,6 @@ use App\Model\InvoiceDocument;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Mocks\InvoiceModelFactoryFactory;
use Doctrine\Common\Collections\ArrayCollection;
trait RendererTestTrait
{
@@ -85,6 +84,10 @@ trait RendererTestTrait
protected function getInvoiceModel(): InvoiceModel
{
$activityId = new \ReflectionProperty(Activity::class, 'id');
$projectId = new \ReflectionProperty(Project::class, 'id');
$userId = new \ReflectionProperty(User::class, 'id');
$user = new User();
$user->setUserIdentifier('one-user');
$user->setTitle('user title');
@@ -107,59 +110,54 @@ trait RendererTestTrait
$pMeta = new ProjectMeta();
$pMeta->setName('foo-project')->setValue('bar-project')->setIsVisible(true);
$project = $this->createMock(Project::class);
$project->method('getId')->willReturn(0);
$project->method('getName')->willReturn('project name');
$project->method('getCustomer')->willReturn($customer);
$project->method('getMetaFields')->willReturn(new ArrayCollection([$pMeta]));
$project->method('getVisibleMetaFields')->willReturn([$pMeta]);
$project = new Project();
$projectId->setValue($project, 0);
$project->setName('project name');
$project->setCustomer($customer);
$project->setMetaField($pMeta);
$aMeta = new ActivityMeta();
$aMeta->setName('foo-activity');
$aMeta->setValue('bar-activity');
$aMeta->setIsVisible(true);
$activity = $this->createMock(Activity::class);
$activity->method('getId')->willReturn(0);
$activity->method('getName')->willReturn('activity description');
$activity->method('getProject')->willReturn($project);
$activity->method('getMetaFields')->willReturn(new ArrayCollection([$aMeta]));
$activity->method('getVisibleMetaFields')->willReturn([$aMeta]);
$activity = new Activity();
$activityId->setValue($activity, 0);
$activity->setName('activity description');
$activity->setProject($project);
$activity->setMetaField($aMeta);
$pMeta2 = new ProjectMeta();
$pMeta2->setName('foo-project')->setValue('bar-project2')->setIsVisible(true);
$project2 = $this->createMock(Project::class);
$project2->method('getId')->willReturn(1);
$project2->method('getName')->willReturn('project 2 name');
$project2->method('getCustomer')->willReturn($customer);
$project2->method('getMetaFields')->willReturn(new ArrayCollection([$pMeta2]));
$project2->method('getVisibleMetaFields')->willReturn([$pMeta2]);
$project2 = new Project();
$projectId->setValue($project2, 1);
$project2->setName('project 2 name');
$project2->setCustomer($customer);
$project2->setMetaField($pMeta2);
$aMeta2 = new ActivityMeta();
$aMeta2->setName('foo-activity');
$aMeta2->setValue('bar-activity2');
$aMeta2->setIsVisible(true);
$activity2 = $this->createMock(Activity::class);
$activity2->method('getId')->willReturn(1);
$activity2->method('getName')->willReturn('activity 1 description');
$activity2->method('getProject')->willReturn($project2);
$activity2->method('getMetaFields')->willReturn(new ArrayCollection([$aMeta2]));
$activity2->method('getVisibleMetaFields')->willReturn([$aMeta2]);
$activity2 = new Activity();
$activityId->setValue($activity2, 1);
$activity2->setName('activity 1 description');
$activity2->setProject($project2);
$activity2->setMetaField($aMeta2);
$pref1 = new UserPreference('foo', 'bar');
$pref2 = new UserPreference('mad', 123.45);
$userMethods = ['getId', 'getPreferenceValue', 'getVisiblePreferences', 'getUsername', 'getUserIdentifier'];
$user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user1->method('getUserIdentifier')->willReturn('foo-bar');
$user1->method('getVisiblePreferences')->willReturn([$pref1, $pref2]);
$user1 = new User();
$user1->setUserIdentifier('foo-bar');
$userId->setValue($user1, 1);
//$user1->method('getPreferenceValue')->willReturn('50');
$user1->addPreference($pref1);
$user1->addPreference($pref2);
$user2 = $this->createMock(User::class);
$user2->method('getId')->willReturn(2);
$user2->method('getUsername')->willReturn('hello-world');
$user2->method('getUserIdentifier')->willReturn('hello-world');
$user2->method('getVisiblePreferences')->willReturn([$pref1, $pref2]);
$user2 = new User();
$userId->setValue($user2, 2);
$user2->setUserIdentifier('hello-world');
$user2->addPreference($pref1);
$user2->addPreference($pref2);
$timesheet = new Timesheet();
$timesheet->setDuration(3600);
@@ -302,12 +300,14 @@ trait RendererTestTrait
$pref1 = new UserPreference('foo', 'bar');
$pref2 = new UserPreference('mad', 123.45);
$user1 = $this->createMock(User::class);
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user1->method('getUserIdentifier')->willReturn('foo-bar');
$user1->method('getVisiblePreferences')->willReturn([$pref1, $pref2]);
$userId = new \ReflectionProperty(User::class, 'id');
$user1 = new User();
$user1->setUserIdentifier('foo-bar');
$user1->addPreference($pref1);
$user1->addPreference($pref2);
$userId->setValue($user1, 1);
//$user1->method('getPreferenceValue')->willReturn('50');
$timesheet = new Timesheet();
$timesheet->setDuration(3600);

View File

@@ -0,0 +1,91 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Entity\User;
use App\Pdf\PdfContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\String\UnicodeString;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityPolicyInterface;
abstract class AbstractPolicyTestCase extends TestCase
{
abstract protected function createPolicy(): SecurityPolicyInterface;
public function testCheckSecurity(): void
{
$sut = $this->createPolicy();
$sut->checkSecurity([], [], []);
$this->expectNotToPerformAssertions();
}
public function testCheckPropertyAllowed(): void
{
$sut = $this->createPolicy();
$sut->checkPropertyAllowed(new \stdClass(), 'foo');
$this->expectNotToPerformAssertions();
}
#[DataProvider('getCheckMethodAllowedData')]
public function testCheckMethodAllowed(object $obj, string $method, ?string $expectedExceptionMessage = null): void
{
$sut = $this->createPolicy();
if ($expectedExceptionMessage !== null) {
$this->expectException(SecurityNotAllowedMethodError::class);
$this->expectExceptionMessage($expectedExceptionMessage);
}
$sut->checkMethodAllowed($obj, $method);
if ($expectedExceptionMessage === null) {
$this->expectNotToPerformAssertions();
}
}
public static function getCheckMethodAllowedData(): array
{
return [
[new ServerBag(), 'get', 'Tried to access server environment'],
[self::createStub(SessionInterface::class), 'getId', 'Tried to access session'],
[new \stdClass(), 'foo', 'Tried to access non-read method'],
[new \stdClass(), 'setFoo', 'Tried to access non-read method'],
[new \stdClass(), 'getFoo'],
[new \stdClass(), 'hasFoo'],
[new \stdClass(), 'isFoo'],
[new UnicodeString(), '__toString'],
// Request
[new Request(), 'get', null],
[new Request(), 'isXmlHttpRequest', 'Tried to call setter() of app variable'],
[new Request(), 'hasSession', 'Tried to call setter() of app variable'],
// PdfContext
[new PdfContext(), 'setOption'],
[new PdfContext(), 'getOption', 'Tried to access forbidden method on PdfContext'],
// AppVariable
[new AppVariable(), 'getRequest'],
[new AppVariable(), 'getUser'],
[new AppVariable(), 'getLocale'],
[new AppVariable(), 'getCharset', 'Tried to access forbidden app variable method'],
// User
[new User(), 'getUsername'],
[new User(), 'getPassword', 'Tried to access user secrets'],
[new User(), 'getTotpSecret', 'Tried to access user secrets'],
[new User(), 'getPlainPassword', 'Tried to access user secrets'],
[new User(), 'getConfirmationToken', 'Tried to access user secrets'],
[new User(), 'getTotpAuthenticationConfiguration', 'Tried to access user secrets'],
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Twig\SecurityPolicy\ChainPolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(ChainPolicy::class)]
class ChainPolicyTest extends TestCase
{
public function testCheckSecurity(): void
{
$policy1 = $this->createMock(SecurityPolicyInterface::class);
$policy1->expects(self::once())->method('checkSecurity')->with(['tag'], ['filter'], ['function']);
$policy2 = $this->createMock(SecurityPolicyInterface::class);
$policy2->expects(self::once())->method('checkSecurity')->with(['tag'], ['filter'], ['function']);
$sut = new ChainPolicy();
$sut->addPolicy($policy1);
$sut->addPolicy($policy2);
$sut->checkSecurity(['tag'], ['filter'], ['function']);
}
public function testCheckMethodAllowed(): void
{
$obj = new \stdClass();
$policy1 = $this->createMock(SecurityPolicyInterface::class);
$policy1->expects(self::once())->method('checkMethodAllowed')->with($obj, 'method');
$policy2 = $this->createMock(SecurityPolicyInterface::class);
$policy2->expects(self::once())->method('checkMethodAllowed')->with($obj, 'method');
$sut = new ChainPolicy();
$sut->addPolicy($policy1);
$sut->addPolicy($policy2);
$sut->checkMethodAllowed($obj, 'method');
}
public function testCheckPropertyAllowed(): void
{
$obj = new \stdClass();
$policy1 = $this->createMock(SecurityPolicyInterface::class);
$policy1->expects(self::once())->method('checkPropertyAllowed')->with($obj, 'property');
$policy2 = $this->createMock(SecurityPolicyInterface::class);
$policy2->expects(self::once())->method('checkPropertyAllowed')->with($obj, 'property');
$sut = new ChainPolicy();
$sut->addPolicy($policy1);
$sut->addPolicy($policy2);
$sut->checkPropertyAllowed($obj, 'property');
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Twig\SecurityPolicy\DefaultPolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(DefaultPolicy::class)]
class DefaultPolicyTest extends AbstractPolicyTestCase
{
protected function createPolicy(): SecurityPolicyInterface
{
return new DefaultPolicy();
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Twig\SecurityPolicy\ExportPolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(ExportPolicy::class)]
class ExportPolicyTest extends AbstractPolicyTestCase
{
protected function createPolicy(): SecurityPolicyInterface
{
return new ExportPolicy();
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Twig\SecurityPolicy;
use App\Entity\User;
use App\Pdf\PdfContext;
use App\Twig\SecurityPolicy\InvoicePolicy;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\String\UnicodeString;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityPolicyInterface;
#[CoversClass(InvoicePolicy::class)]
class InvoicePolicyTest extends TestCase
{
protected function createPolicy(): SecurityPolicyInterface
{
return new InvoicePolicy();
}
public function testCheckSecurity(): void
{
$sut = $this->createPolicy();
$sut->checkSecurity([], [], []);
$this->expectNotToPerformAssertions();
}
#[DataProvider('getCheckMethodAllowedData')]
public function testCheckMethodAllowed(object $obj, string $method, ?string $expectedExceptionMessage = null): void
{
$sut = $this->createPolicy();
if ($expectedExceptionMessage !== null) {
$this->expectException(SecurityNotAllowedMethodError::class);
$this->expectExceptionMessage($expectedExceptionMessage);
}
$sut->checkMethodAllowed($obj, $method);
if ($expectedExceptionMessage === null) {
$this->expectNotToPerformAssertions();
}
}
public static function getCheckMethodAllowedData(): array
{
return [
[new ServerBag(), 'get', 'Tried to access server environment'],
[self::createStub(SessionInterface::class), 'getId', 'Tried to access session'],
[new \stdClass(), 'foo', 'Tried to access non-read method'],
[new \stdClass(), 'setFoo', 'Tried to access non-read method'],
[new \stdClass(), 'getFoo'],
[new \stdClass(), 'hasFoo'],
[new \stdClass(), 'isFoo'],
[new UnicodeString(), '__toString'],
// Request
[new Request(), 'get', null],
[new Request(), 'isXmlHttpRequest', 'Tried to call setter() of app variable'],
[new Request(), 'hasSession', 'Tried to call setter() of app variable'],
// PdfContext
[new PdfContext(), 'setOption'],
[new PdfContext(), 'getOption', 'Tried to access forbidden method on PdfContext'],
// AppVariable
[new AppVariable(), 'getRequest'],
[new AppVariable(), 'getUser'],
[new AppVariable(), 'getLocale'],
[new AppVariable(), 'getCharset', 'Tried to access forbidden app variable method'],
// User
[new User(), 'getUsername'],
[new User(), 'getPassword', 'Tried to access user secrets'],
[new User(), 'getTotpSecret', 'Tried to access user secrets'],
[new User(), 'getPlainPassword', 'Tried to access user secrets'],
[new User(), 'getConfirmationToken', 'Tried to access user secrets'],
[new User(), 'getTotpAuthenticationConfiguration', 'Tried to access user secrets'],
];
}
}

1228
yarn.lock

File diff suppressed because it is too large Load Diff