Release 2.57 (#5929)
This commit is contained in:
131
AGENTS.md
Normal file
131
AGENTS.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Kimai Core Agent Guide
|
||||
|
||||
Use this file when working in the Kimai core repository.
|
||||
|
||||
## Stack
|
||||
|
||||
- Kimai is a professional open source time-tracking application
|
||||
- PHP versions: 8.2, 8.3, 8.4, 8.5
|
||||
- Main framework: Symfony 6.4
|
||||
- Core libraries: Doctrine, Twig
|
||||
- API libraries: FOSRestBundle, NelmioApiDocBundle
|
||||
- Frontend: Bootstrap with Tabler.io
|
||||
- Frontend build: Webpack Encore via `symfony/webpack-encore`
|
||||
- Package managers: Composer and Yarn
|
||||
- Tests: PHPUnit
|
||||
- Code styles: PhpCsFixer
|
||||
- Static analysis: PHPStan
|
||||
- Project information in README.md
|
||||
- Translations managed with Weblate online service
|
||||
|
||||
## Scope
|
||||
|
||||
- This guide applies to Kimai core only
|
||||
- Work in `var/plugins/` is out of scope unless explicitly requested
|
||||
- Each subdirectory in `var/plugins/` is a separate Kimai plugin and its own Git repository
|
||||
- In fresh installations, `var/data/` and `var/plugins/` are empty
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `.docker/` Docker image build files
|
||||
- `.github/` GitHub Actions and repository metadata
|
||||
- `assets/` JavaScript and Sass sources
|
||||
- `bin/` executable entry points, especially `bin/console`
|
||||
- `config/` Symfony configuration and bundle setup
|
||||
- `migrations/` Doctrine migrations for installs and upgrades
|
||||
- `public/` web root with `index.php`
|
||||
- `public/build/` generated frontend assets
|
||||
- `public/bundles/` generated public bundle assets
|
||||
- `src/` core PHP source code
|
||||
- `src/API/` the JSON API
|
||||
- `templates/` Twig templates
|
||||
- `tests/` PHPUnit tests
|
||||
- `translations/` XLIFF files named `<component>.<locale>.xlf`
|
||||
- `var/` runtime storage and generated content
|
||||
- `vendor/` Composer dependencies
|
||||
|
||||
## Never Touch
|
||||
|
||||
- Do not read from or write to `var/cache/`, it is Symfony-managed internal state
|
||||
- Do not modify `vendor/`
|
||||
- Do not modify `var/data/`
|
||||
- Do not modify `var/log/`
|
||||
- Do not modify `public/build/`, generated frontend assets
|
||||
- Do not modify `public/bundles/`, frintend assets from plugins
|
||||
- Do not modify plugins in `var/plugins/` unless explicitly asked
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
- Read the surrounding code before editing
|
||||
- Follow existing local patterns before introducing new abstractions
|
||||
- Keep changes small and targeted
|
||||
- Keep code, identifiers, comments, branches, commit text, and documentation in English
|
||||
- Ask before touching security-sensitive areas such as authentication, authorization, or permissions
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
- Do not introduce new composer packages without prior discussion
|
||||
- Prefer services over static helper classes
|
||||
- Keep business logic out of controllers
|
||||
- Use Twig templates for HTML output
|
||||
- Preserve backward compatibility for upgrades
|
||||
|
||||
## Database Rules
|
||||
|
||||
- Doctrine entity changes affecting the schema require a migration file
|
||||
- Generate migration with `bin/console doctrine:migrations:diff`
|
||||
- Prefer `Doctrine\DBAL\Schema` in migrations over inline SQL
|
||||
|
||||
## Frontend and Translation Rules
|
||||
|
||||
- Build on existing Bootstrap and Tabler patterns
|
||||
- Do not introduce new frontend frameworks without prior discussion
|
||||
- Keep English translations updated whenever translations change
|
||||
- English is the Weblate default language and Kimai fallback language
|
||||
- Use Twig `|trans` for user-facing text instead of hardcoded strings
|
||||
- Use FontAwesome 6 names for icons
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- Every PHP class in `src/`, except interfaces, should have a matching PHPUnit test
|
||||
- Map `src/<directory>/<ClassName>.php` to `tests/<directory>/<ClassName>Test.php`
|
||||
- Cover all public methods with tests
|
||||
- Follow the existing test style in the target area such as controller, event, voter, or service tests
|
||||
|
||||
## Validation
|
||||
|
||||
- Always run `./php-cs-fixer.sh core`
|
||||
- Run `./phpstan.sh core` for changes in `src/`
|
||||
- Run `./phpstan.sh test` for changes in `tests/`
|
||||
- For focused checks, run `vendor/bin/phpunit tests/<directory>/<TestClassName>.php`
|
||||
- Use `composer tests-unit` for broader validation without expensive end-to-end coverage
|
||||
- Use `composer tests` when the change justifies running the full suite
|
||||
- If tests fail, remove stale cache with `rm -r ./var/cache/test/` to cause a rebuild
|
||||
|
||||
## Git Rules
|
||||
|
||||
- Avoid working directly on `main`
|
||||
- Small fixes should target the active `release-x.y.z` branch
|
||||
- Larger changes should go to descriptive `snake_case` feature branches
|
||||
- Agents may create branches when needed
|
||||
- Agents must not create commits unless explicitly asked
|
||||
- Commits are normally created by the maintainer
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- Use strict comparisons such as `===` and `!==`
|
||||
- Prefer constructor promotion for dependency injection
|
||||
- Use PHP attributes for routing, mapping, and configuration where established
|
||||
- Use `camelCase` for variables and methods
|
||||
- Use 4-space indentation
|
||||
- Use single quotes for strings in PHP, JavaScript, and CSS unless the local code style requires otherwise
|
||||
- Use modern HTML5, Twig, and ES6+ syntax
|
||||
|
||||
## Security Focus
|
||||
|
||||
- Prevent XSS
|
||||
- Prevent CSRF issues
|
||||
- Prevent SQL or command injection patterns
|
||||
- Prevent auth bypasses
|
||||
- Prevent open redirects
|
||||
- Apply Rate-Limiting in authentication flows
|
||||
@@ -85,6 +85,7 @@ There is one simple rule in our "Code of conduct": Don't be an ass!
|
||||
- Mastodon: [@kimai](https://phpc.social/@kimai)
|
||||
- Youtube: [@kimai_org](https://www.youtube.com/@kimai_org)
|
||||
- LinkedIn: [@kimai-org](https://www.linkedin.com/company/kimai-org/)
|
||||
- Reddit: [r/kimai](https://www.reddit.com/r/kimai/)
|
||||
|
||||
### Credits
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
|
||||
*/
|
||||
activateSelectPickerByElement(node)
|
||||
{
|
||||
// TODO cannot update tom-select to >= 2.6.0 due to https://github.com/orchidjs/tom-select/pull/993#issuecomment-4489286080
|
||||
let plugins = ['change_listener'];
|
||||
|
||||
const isMultiple = node.multiple !== undefined && node.multiple === true;
|
||||
@@ -85,14 +86,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
|
||||
sortField:[{field: '$order'}, {field: '$score'}],
|
||||
// required so it works in table.responsive, but requires z-index 1056, because bootstrap modal would otherwise hide it
|
||||
dropdownParent: 'body',
|
||||
};
|
||||
|
||||
let render = {
|
||||
onOptionAdd: (value) => {
|
||||
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let render = {};
|
||||
|
||||
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
|
||||
options.render = {...render, ...this.getRenderer(rendererType)};
|
||||
|
||||
|
||||
683
composer.lock
generated
683
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ nelmio_api_doc:
|
||||
models:
|
||||
use_jms: true
|
||||
names:
|
||||
- { alias: Comment, type: App\Entity\CommentTableTypeTrait, groups: [Default] }
|
||||
- { alias: CommentForm, type: App\Form\API\CommentApiForm, groups: [Default] }
|
||||
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer, Customer_Entity] }
|
||||
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer, Customer_Entity, Not_Expanded] }
|
||||
- { alias: Customer, type: App\Entity\Customer, groups: [Default, Not_Expanded] }
|
||||
|
||||
60
package.json
60
package.json
@@ -23,37 +23,37 @@
|
||||
"defaults"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/core": "^7",
|
||||
"@babel/eslint-parser": "^7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7",
|
||||
"@babel/preset-env": "^7",
|
||||
"@eslint/js": "^9",
|
||||
"@fortawesome/fontawesome-free": "^6",
|
||||
"@fullcalendar/bootstrap5": "^5",
|
||||
"@fullcalendar/core": "^5",
|
||||
"@fullcalendar/daygrid": "^5",
|
||||
"@fullcalendar/google-calendar": "^5",
|
||||
"@fullcalendar/icalendar": "^5",
|
||||
"@fullcalendar/interaction": "^5",
|
||||
"@fullcalendar/timegrid": "^5",
|
||||
"@popperjs/core": "^2",
|
||||
"@symfony/webpack-encore": "^5",
|
||||
"@tabler/core": "1.4",
|
||||
"bootstrap": "^5.3",
|
||||
"chart.js": "^4",
|
||||
"core-js": "^3",
|
||||
"dompurify": "^3",
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@fullcalendar/bootstrap5": "^5.11.5",
|
||||
"@fullcalendar/core": "^5.11.5",
|
||||
"@fullcalendar/daygrid": "^5.11.5",
|
||||
"@fullcalendar/google-calendar": "^5.11.5",
|
||||
"@fullcalendar/icalendar": "^5.11.5",
|
||||
"@fullcalendar/interaction": "^5.11.5",
|
||||
"@fullcalendar/timegrid": "^5.11.5",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@symfony/webpack-encore": "^5.3.1",
|
||||
"@tabler/core": "^1.4.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"eslint": "^9.39.4",
|
||||
"globals": "^15.15.0",
|
||||
"gridstack": "^7.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"litepicker": "^2",
|
||||
"luxon": "^3",
|
||||
"sass": "^1",
|
||||
"sass-loader": "^16",
|
||||
"tom-select": "^2.4.3",
|
||||
"webpack": "^5",
|
||||
"webpack-cli": "^5"
|
||||
"litepicker": "^2.0.12",
|
||||
"luxon": "^3.7.2",
|
||||
"sass": "^1.99.0",
|
||||
"sass-loader": "^16.0.8",
|
||||
"tom-select": "2.5.2",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
|
||||
39
phpstan.neon
39
phpstan.neon
@@ -43,7 +43,9 @@ parameters:
|
||||
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
|
||||
ignoreErrors:
|
||||
- identifier: offsetAccess.notFound
|
||||
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
|
||||
- message: '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
|
||||
- message: '#^Call to deprecated method setApiToken\(\) of class App\\Entity\\User#'
|
||||
- message: '#^Call to deprecated method getPlainApiToken\(\) of class App\\Entity\\User#'
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Activity\\:\\:getMetaField\\(\\) expects string, mixed given\\.$#"
|
||||
@@ -1640,21 +1642,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/CustomerEditForm.php
|
||||
|
||||
-
|
||||
message: "#^Class App\\\\Form\\\\DataTransformer\\\\SearchTermTransformer implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
|
||||
count: 1
|
||||
path: src/Form/DataTransformer/SearchTermTransformer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$searchTerm \\(App\\\\Utils\\\\SearchTerm\\|null\\) of method App\\\\Form\\\\DataTransformer\\\\SearchTermTransformer\\:\\:transform\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<mixed,mixed\\>\\:\\:transform\\(\\)$#"
|
||||
count: 1
|
||||
path: src/Form/DataTransformer/SearchTermTransformer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$searchTerm \\(string\\|null\\) of method App\\\\Form\\\\DataTransformer\\\\SearchTermTransformer\\:\\:reverseTransform\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<mixed,mixed\\>\\:\\:reverseTransform\\(\\)$#"
|
||||
count: 1
|
||||
path: src/Form/DataTransformer/SearchTermTransformer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$value \\(array\\<string\\>\\) of method App\\\\Form\\\\DataTransformer\\\\StringToArrayTransformer\\:\\:transform\\(\\) should be contravariant with parameter \\$value \\(array\\|null\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<array,string\\>\\:\\:transform\\(\\)$#"
|
||||
count: 1
|
||||
@@ -1710,11 +1697,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Model/SystemConfiguration.php
|
||||
|
||||
-
|
||||
message: "#^PHPDoc tag @var for variable \\$repository contains generic class Doctrine\\\\ORM\\\\EntityRepository but does not specify its types\\: TEntityClass$#"
|
||||
count: 1
|
||||
path: src/Form/MultiUpdate/MultiUpdateTable.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'customer' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -2550,16 +2532,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/ActivityType.php
|
||||
|
||||
-
|
||||
message: "#^Class App\\\\Form\\\\Type\\\\ColorChoiceType implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
|
||||
count: 1
|
||||
path: src/Form/Type/ColorChoiceType.php
|
||||
|
||||
-
|
||||
message: "#^Class App\\\\Form\\\\Type\\\\ColorPickerType implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
|
||||
count: 1
|
||||
path: src/Form/Type/ColorPickerType.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Type\\\\CustomerType\\:\\:getChoiceAttributes\\(\\) has parameter \\$key with no type specified\\.$#"
|
||||
count: 1
|
||||
@@ -2710,11 +2682,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$date of method App\\\\Invoice\\\\InvoiceFormatter\\:\\:getFormattedDateTime\\(\\) expects DateTimeInterface, DateTime\\|null given\\.$#"
|
||||
count: 2
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelProjectHydrator\\:\\:getBudgetValues\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
<php>
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
<ini name="date.timezone" value="UTC"/>
|
||||
<ini name="intl.default_locale" value="en"/>
|
||||
<ini name="date.timezone" value="Europe/Vienna"/>
|
||||
<ini name="date.timezone" value="Pacific/Tahiti"/>
|
||||
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
|
||||
<env name="APP_ENV" value="test" force="true"/>
|
||||
|
||||
@@ -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)}]);
|
||||
1
public/build/app-rtl.7a875ca7.js
Normal file
1
public/build/app-rtl.7a875ca7.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{2395:function(i,n,u){u(876)},876:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2395,i(i.s=n)}]);
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.0554bae5.js
Normal file
2
public/build/app.0554bae5.js
Normal file
File diff suppressed because one or more lines are too long
@@ -176,4 +176,4 @@
|
||||
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
|
||||
*/
|
||||
|
||||
/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */
|
||||
/*! @license DOMPurify 3.4.2 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.2/LICENSE */
|
||||
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
2
public/build/calendar.93b44399.js
Normal file
2
public/build/calendar.93b44399.js
Normal file
File diff suppressed because one or more lines are too long
2
public/build/dashboard.23a8c378.js
Normal file
2
public/build/dashboard.23a8c378.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,19 +3,19 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.f0f8091d.js"
|
||||
"/build/app.0554bae5.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.99ea4166.css"
|
||||
"/build/app.49955ea2.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app-rtl.15853b82.js"
|
||||
"/build/app-rtl.7a875ca7.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.16262d9a.css"
|
||||
"/build/app-rtl.e8e3029e.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"invoice": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice.42b319e4.js"
|
||||
"/build/invoice.773af9c4.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/invoice.36018785.css"
|
||||
@@ -54,7 +54,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.13247e65.js"
|
||||
"/build/calendar.93b44399.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -63,7 +63,7 @@
|
||||
"dashboard": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/dashboard.9708ae5e.js"
|
||||
"/build/dashboard.23a8c378.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
@@ -72,7 +72,7 @@
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/highlight.0296a734.js"
|
||||
"/build/highlight.14a92a92.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/highlight.98bf3927.css"
|
||||
@@ -81,22 +81,22 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.f0f8091d.js": "sha384-F6UUWeiIwbFffkZMRmbczLyw5tuOvtUNLuX1/iY6ZfncN1vYGSgalEumAp4HXFvX",
|
||||
"/build/app.99ea4166.css": "sha384-OF0ozOygdShRhEacN7Tb8YtDSXABczVqjmXRYQjX1YGTmuRQMNCipFTqzHd9IyCH",
|
||||
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
|
||||
"/build/app-rtl.16262d9a.css": "sha384-CjN7UFkBszmM9k6xfN8LWH79IOsgpVTwwHoffvAOc04j9+y904uDw/Y+LnSQmrDj",
|
||||
"/build/app.0554bae5.js": "sha384-+CYFMOy4OceuorljhlSXlzy4GQrVl+BLM9fdbuVV7cXhbVUVf0X2BwRchHyqgM8a",
|
||||
"/build/app.49955ea2.css": "sha384-8ix/CKnR2d1mU3tMEFJPrOOPtQA2tdzHugEJMnODX+7my6dFwlworNdfZwrwuvz5",
|
||||
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
|
||||
"/build/app-rtl.e8e3029e.css": "sha384-21kGyBRbajbE/gt4g8ej+clQSGAvYx1BMmiUMRTAE3ZVpMb5bvO/kVRcdKuV97J5",
|
||||
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.42b319e4.js": "sha384-xxK7sCe/ZhTjMPFPeX1xvILURxNRZz2hJHZxAGVaw9zE6TC++2/6y2eKqg8cz852",
|
||||
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",
|
||||
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
|
||||
"/build/calendar.13247e65.js": "sha384-8b3wBuxn8m2FsxLMtbIpZTg1SGBX9b+0dYRTnF63mGQfcXhRGHKtUs5REwVyhYiP",
|
||||
"/build/calendar.93b44399.js": "sha384-TPp5gC/g3f9BeA7oHvUMcIzbMrQ3u1/w0cWvUJWRDCO0yiXJtPpQ3NpdYdcVyQol",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
|
||||
"/build/dashboard.23a8c378.js": "sha384-aXsmLs+Zgb7OogCLNVUyqup7ogP3u1AVcr+TT8qL40c7rSN1i9/1kXEfus3Up9R4",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
"/build/highlight.0296a734.js": "sha384-vGFKI/KM+uyGIlzKsKhQh4w41hQteB4u14oRYqObMT03/YTZXq6Re5nUN6g3sGQH",
|
||||
"/build/highlight.14a92a92.js": "sha384-leVW73aB0RkuTAEllt5mQdZBFLSnoD1mOAbLB9dGgDhVQpDsLdi+yqmr8Cch8C7c",
|
||||
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
1
public/build/highlight.14a92a92.js
Normal file
1
public/build/highlight.14a92a92.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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)}]);
|
||||
1
public/build/invoice.773af9c4.js
Normal file
1
public/build/invoice.773af9c4.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{4820:function(i,n,u){u(3631)},3631:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=4820,i(i.s=n)}]);
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"build/app.css": "/build/app.99ea4166.css",
|
||||
"build/app.js": "/build/app.f0f8091d.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.16262d9a.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
|
||||
"build/app.css": "/build/app.49955ea2.css",
|
||||
"build/app.js": "/build/app.0554bae5.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.e8e3029e.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.395749ab.js",
|
||||
"build/invoice.css": "/build/invoice.36018785.css",
|
||||
"build/invoice.js": "/build/invoice.42b319e4.js",
|
||||
"build/invoice.js": "/build/invoice.773af9c4.js",
|
||||
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.56f16a68.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.13247e65.js",
|
||||
"build/calendar.js": "/build/calendar.93b44399.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
|
||||
"build/dashboard.js": "/build/dashboard.23a8c378.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
"build/highlight.js": "/build/highlight.0296a734.js",
|
||||
"build/highlight.js": "/build/highlight.14a92a92.js",
|
||||
"build/runtime.js": "/build/runtime.684e9f6d.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",
|
||||
|
||||
@@ -191,7 +191,7 @@ final class ActivityController extends BaseApiController
|
||||
* Delete activity
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked timesheets.
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}`?
|
||||
*/
|
||||
#[IsGranted('delete', 'activity')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]
|
||||
|
||||
@@ -11,8 +11,10 @@ namespace App\API;
|
||||
|
||||
use App\Customer\CustomerService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerComment;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\User;
|
||||
use App\Form\API\CommentApiForm;
|
||||
use App\Form\API\CustomerApiEditForm;
|
||||
use App\Form\API\CustomerRateApiForm;
|
||||
use App\Repository\CustomerRateRepository;
|
||||
@@ -35,6 +37,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[OA\Tag(name: 'Customer')]
|
||||
final class CustomerController extends BaseApiController
|
||||
{
|
||||
private const GROUPS_COMMENT = ['Default', 'Not_Expanded'];
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Customer', 'Customer_Entity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Customer'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
|
||||
@@ -182,7 +185,7 @@ final class CustomerController extends BaseApiController
|
||||
* Delete customer
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}`?
|
||||
*/
|
||||
#[IsGranted('delete', 'customer')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
|
||||
@@ -299,4 +302,103 @@ final class CustomerController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch comments for customer
|
||||
*/
|
||||
#[IsGranted('view', 'customer')]
|
||||
#[IsGranted('comments', 'customer')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of customer comments', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Comment')))]
|
||||
#[OA\Parameter(name: 'id', description: 'The customer whose comments will be returned', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/comments', name: 'get_customer_comments', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
public function getCommentsAction(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer): Response
|
||||
{
|
||||
$comments = $this->repository->getComments($customer);
|
||||
|
||||
$view = new View($comments, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COMMENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment for customer
|
||||
*/
|
||||
#[IsGranted('view', 'customer')]
|
||||
#[IsGranted('comments', 'customer')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the newly created customer comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The customer to add the comment for', in: 'path', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CommentForm'))]
|
||||
#[Route(path: '/{id}/comments', name: 'post_customer_comment', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer, Request $request): Response
|
||||
{
|
||||
$comment = new CustomerComment($customer);
|
||||
$comment->setCreatedBy($this->getUser());
|
||||
|
||||
$form = $this->createForm(CommentApiForm::class, $comment, [
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
$form->setData($comment);
|
||||
$form->submit($request->request->all(), false);
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
return $this->viewHandler->handle(new View($form, Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
$this->repository->saveComment($comment);
|
||||
|
||||
$view = new View($comment, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COMMENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin customer comment
|
||||
*
|
||||
* This toggles the `pinned` status of the given comment.
|
||||
*/
|
||||
#[IsGranted('view', 'customer')]
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[IsGranted('comments', 'customer')]
|
||||
#[OA\Patch(responses: [new OA\Response(response: 200, description: 'Returns the updated customer comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The customer whose comment will be pinned or unpinned', in: 'path', required: true)]
|
||||
#[OA\Parameter(name: 'comment', description: 'The comment whose pinned status will be toggled', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/comments/{comment}/pin', name: 'toggle_customer_comment_pin', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['PATCH'])]
|
||||
public function toggleCommentPin(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer, #[MapEntity(mapping: ['comment' => 'id'])] CustomerComment $comment): Response
|
||||
{
|
||||
if ($comment->getCustomer() !== $customer) {
|
||||
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to customer %s', $comment->getId(), $customer->getId()));
|
||||
}
|
||||
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
$this->repository->saveComment($comment);
|
||||
|
||||
$view = new View($comment, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COMMENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete customer comment
|
||||
*/
|
||||
#[IsGranted('view', 'customer')]
|
||||
#[IsGranted('edit', 'customer')]
|
||||
#[IsGranted('comments', 'customer')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
#[OA\Parameter(name: 'id', description: 'The customer whose comment will be removed', in: 'path', required: true)]
|
||||
#[OA\Parameter(name: 'comment', description: 'The comment to remove', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/comments/{comment}', name: 'delete_customer_comment', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Customer $customer, #[MapEntity(mapping: ['comment' => 'id'])] CustomerComment $comment): Response
|
||||
{
|
||||
if ($comment->getCustomer() !== $customer) {
|
||||
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to customer %s', $comment->getId(), $customer->getId()));
|
||||
}
|
||||
|
||||
$this->repository->deleteComment($comment);
|
||||
|
||||
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectComment;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\User;
|
||||
use App\Form\API\CommentApiForm;
|
||||
use App\Form\API\ProjectApiEditForm;
|
||||
use App\Form\API\ProjectRateApiForm;
|
||||
use App\Project\ProjectService;
|
||||
@@ -37,6 +39,7 @@ use Symfony\Component\Validator\Constraints;
|
||||
#[OA\Tag(name: 'Project')]
|
||||
final class ProjectController extends BaseApiController
|
||||
{
|
||||
private const GROUPS_COMMENT = ['Default', 'Not_Expanded'];
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Project', 'Project_Entity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Project'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
|
||||
@@ -238,7 +241,7 @@ final class ProjectController extends BaseApiController
|
||||
* Delete project
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked activities and timesheets.
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}` instead?
|
||||
* Do you want to use `PATCH` instead and mark it as inactive with `{visible: false}`?
|
||||
*/
|
||||
#[IsGranted('delete', 'project')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
|
||||
@@ -355,4 +358,103 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch comments for project
|
||||
*/
|
||||
#[IsGranted('view', 'project')]
|
||||
#[IsGranted('comments', 'project')]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of project comments', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Comment')))]
|
||||
#[OA\Parameter(name: 'id', description: 'The project whose comments will be returned', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/comments', name: 'get_project_comments', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
public function getCommentsAction(#[MapEntity(mapping: ['id' => 'id'])] Project $project): Response
|
||||
{
|
||||
$comments = $this->repository->getComments($project);
|
||||
|
||||
$view = new View($comments, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COMMENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment for project
|
||||
*/
|
||||
#[IsGranted('view', 'project')]
|
||||
#[IsGranted('comments', 'project')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the newly created project comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The project to add the comment for', in: 'path', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CommentForm'))]
|
||||
#[Route(path: '/{id}/comments', name: 'post_project_comment', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Project $project, Request $request): Response
|
||||
{
|
||||
$comment = new ProjectComment($project);
|
||||
$comment->setCreatedBy($this->getUser());
|
||||
|
||||
$form = $this->createForm(CommentApiForm::class, $comment, [
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
$form->setData($comment);
|
||||
$form->submit($request->request->all(), false);
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
return $this->viewHandler->handle(new View($form, Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
$this->repository->saveComment($comment);
|
||||
|
||||
$view = new View($comment, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COMMENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin project comment
|
||||
*
|
||||
* This toggles the `pinned` status of the given comment.
|
||||
*/
|
||||
#[IsGranted('view', 'project')]
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[IsGranted('comments', 'project')]
|
||||
#[OA\Patch(responses: [new OA\Response(response: 200, description: 'Returns the updated project comment', content: new OA\JsonContent(ref: '#/components/schemas/Comment'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The project whose comment will be pinned or unpinned', in: 'path', required: true)]
|
||||
#[OA\Parameter(name: 'comment', description: 'The comment whose pinned status will be toggled', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/comments/{comment}/pin', name: 'toggle_project_comment_pin', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['PATCH'])]
|
||||
public function toggleCommentPin(#[MapEntity(mapping: ['id' => 'id'])] Project $project, #[MapEntity(mapping: ['comment' => 'id'])] ProjectComment $comment): Response
|
||||
{
|
||||
if ($comment->getProject() !== $project) {
|
||||
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to project %s', $comment->getId(), $project->getId()));
|
||||
}
|
||||
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
$this->repository->saveComment($comment);
|
||||
|
||||
$view = new View($comment, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COMMENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete project comment
|
||||
*/
|
||||
#[IsGranted('view', 'project')]
|
||||
#[IsGranted('edit', 'project')]
|
||||
#[IsGranted('comments', 'project')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
#[OA\Parameter(name: 'id', description: 'The project whose comment will be removed', in: 'path', required: true)]
|
||||
#[OA\Parameter(name: 'comment', description: 'The comment to remove', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/comments/{comment}', name: 'delete_project_comment', requirements: ['id' => '\d+', 'comment' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteCommentAction(#[MapEntity(mapping: ['id' => 'id'])] Project $project, #[MapEntity(mapping: ['comment' => 'id'])] ProjectComment $comment): Response
|
||||
{
|
||||
if ($comment->getProject() !== $project) {
|
||||
throw $this->createAccessDeniedException(\sprintf('Comment %s does not belong to project %s', $comment->getId(), $project->getId()));
|
||||
}
|
||||
|
||||
$this->repository->deleteComment($comment);
|
||||
|
||||
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
if (!$seeAll) {
|
||||
foreach ($userRepository->findByIds($users) as $user) {
|
||||
if (!$this->isGranted('access_user', $user)) {
|
||||
throw $this->createAccessDeniedException('Cannot access user: ' . $user->getId());
|
||||
}
|
||||
$query->addUser($user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,11 @@ final class SystemConfiguration
|
||||
return (bool) $this->find('user.registration');
|
||||
}
|
||||
|
||||
public function getAuthenticationTheme(): string
|
||||
{
|
||||
return $this->getString('user.theme', 'auto');
|
||||
}
|
||||
|
||||
public function getPasswordResetTokenLifetime(): int
|
||||
{
|
||||
return (int) $this->find('user.password_reset_token_ttl');
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.56.0';
|
||||
public const VERSION = '2.57.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25600;
|
||||
public const VERSION_ID = 25700;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -189,6 +189,10 @@ final class ActivityController extends AbstractController
|
||||
#[IsGranted('edit', 'activity')]
|
||||
public function editRateAction(Activity $activity, ActivityRate $rate, Request $request, ActivityRateRepository $repository): Response
|
||||
{
|
||||
if ($rate->getActivity() !== $activity) {
|
||||
throw $this->createAccessDeniedException('Trying to edit rate and activity that do not belong together.');
|
||||
}
|
||||
|
||||
return $this->rateFormAction($activity, $rate, $request, $repository, $this->generateUrl('admin_activity_rate_edit', ['id' => $activity->getId(), 'rate' => $rate->getId()]));
|
||||
}
|
||||
|
||||
@@ -231,6 +235,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/create/{project}', name: 'admin_activity_create_with_project', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_activity')]
|
||||
#[IsGranted('edit', 'project')]
|
||||
public function createWithProjectAction(Project $project, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
return $this->createActivity($request, $activityService, $configuration, $project);
|
||||
|
||||
@@ -44,8 +44,6 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
@@ -170,29 +168,6 @@ final class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/comment_delete/{token}', name: 'customer_comment_delete', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
|
||||
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.delete', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('comment.delete');
|
||||
|
||||
try {
|
||||
$this->repository->deleteComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/comment_add', name: 'customer_comment_add', methods: ['POST'])]
|
||||
#[IsGranted('comments', 'customer')]
|
||||
public function addCommentAction(Customer $customer, Request $request): Response
|
||||
@@ -213,30 +188,6 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/comment_pin/{token}', name: 'customer_comment_pin', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
|
||||
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.pin', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('comment.pin');
|
||||
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
try {
|
||||
$this->repository->saveComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
@@ -371,6 +322,10 @@ final class CustomerController extends AbstractController
|
||||
#[IsGranted('edit', 'customer')]
|
||||
public function editRateAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository): Response
|
||||
{
|
||||
if ($rate->getCustomer() !== $customer) {
|
||||
throw $this->createAccessDeniedException('Trying to edit rate and customer that do not belong together.');
|
||||
}
|
||||
|
||||
return $this->rateFormAction($customer, $rate, $request, $repository, $this->generateUrl('admin_customer_rate_edit', ['id' => $customer->getId(), 'rate' => $rate->getId()]));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ final class FavoriteController extends AbstractController
|
||||
|
||||
#[Route(path: '/timesheet/add/{id}', name: 'favorites_timesheets_add', methods: ['GET'])]
|
||||
#[IsGranted('start_own_timesheet')]
|
||||
#[IsGranted('is_owner', 'timesheet')]
|
||||
public function add(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
|
||||
{
|
||||
$favoriteRecordService->addFavorite($timesheet);
|
||||
@@ -37,6 +38,7 @@ final class FavoriteController extends AbstractController
|
||||
|
||||
#[Route(path: '/timesheet/remove/{id}', name: 'favorites_timesheets_remove', methods: ['GET'])]
|
||||
#[IsGranted('start_own_timesheet')]
|
||||
#[IsGranted('is_owner', 'timesheet')]
|
||||
public function remove(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
|
||||
{
|
||||
$favoriteRecordService->removeFavorite($timesheet);
|
||||
|
||||
@@ -161,6 +161,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_project')]
|
||||
#[IsGranted('edit', 'customer')]
|
||||
public function createWithCustomerAction(Request $request, Customer $customer, ProjectService $projectService, SystemConfiguration $configuration): Response
|
||||
{
|
||||
return $this->createProject($request, $projectService, $configuration, $customer);
|
||||
@@ -198,29 +199,6 @@ final class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/comment_delete/{token}', name: 'project_comment_delete', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
|
||||
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$projectId = $comment->getProject()->getId();
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.delete', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('comment.delete');
|
||||
|
||||
try {
|
||||
$this->repository->deleteComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/comment_add', name: 'project_comment_add', methods: ['POST'])]
|
||||
#[IsGranted('comments', 'project')]
|
||||
public function addCommentAction(Project $project, Request $request): Response
|
||||
@@ -241,30 +219,6 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/comment_pin/{token}', name: 'project_comment_pin', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
|
||||
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$projectId = $comment->getProject()->getId();
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.pin', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('comment.pin');
|
||||
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
try {
|
||||
$this->repository->saveComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
@@ -394,6 +348,10 @@ final class ProjectController extends AbstractController
|
||||
#[IsGranted('edit', 'project')]
|
||||
public function editRateAction(Project $project, ProjectRate $rate, Request $request, ProjectRateRepository $repository): Response
|
||||
{
|
||||
if ($rate->getProject() !== $project) {
|
||||
throw $this->createAccessDeniedException('Trying to edit rate and project that do not belong together.');
|
||||
}
|
||||
|
||||
return $this->rateFormAction($project, $rate, $request, $repository, $this->generateUrl('admin_project_rate_edit', ['id' => $project->getId(), 'rate' => $rate->getId()]));
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,9 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setLabel('user_auth_password_reset_token_ttl')
|
||||
->setConstraints([new NotNull(), new GreaterThanOrEqual(['value' => 60])])
|
||||
->setType(IntegerType::class),
|
||||
(new Configuration('user.theme'))
|
||||
->setLabel('skin')
|
||||
->setType(SkinType::class),
|
||||
]);
|
||||
|
||||
$allowRegistration = $this->systemConfiguration->find('features.user_registration');
|
||||
|
||||
@@ -556,6 +556,15 @@ final class Configuration implements ConfigurationInterface
|
||||
->booleanNode('login')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->scalarNode('theme')
|
||||
->defaultValue('auto')
|
||||
->validate()
|
||||
->ifTrue(static function ($v) {
|
||||
return (!\in_array($v, ['auto', 'default', 'dark']));
|
||||
})
|
||||
->thenInvalid('The theme must be one of: "auto", "default", "dark"')
|
||||
->end()
|
||||
->end()
|
||||
->booleanNode('registration')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
@@ -625,7 +634,15 @@ final class Configuration implements ConfigurationInterface
|
||||
->children()
|
||||
->scalarNode('timezone')->defaultNull()->end()
|
||||
->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end()
|
||||
->scalarNode('theme')->defaultValue('auto')->end()
|
||||
->scalarNode('theme')
|
||||
->defaultValue('auto')
|
||||
->validate()
|
||||
->ifTrue(static function ($v) {
|
||||
return (!\in_array($v, ['auto', 'default', 'dark']));
|
||||
})
|
||||
->thenInvalid('The theme must be one of: "auto", "default", "dark"')
|
||||
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
trait CommentTableTypeTrait
|
||||
@@ -18,19 +19,29 @@ trait CommentTableTypeTrait
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'message', type: Types::TEXT, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $message = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?User $createdBy = null;
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?\DateTime $createdAt = null;
|
||||
#[ORM\Column(name: 'pinned', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private bool $pinned = false;
|
||||
|
||||
public function getId(): ?int
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_customers_comments')]
|
||||
#[ORM\Index(columns: ['customer_id'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
class CustomerComment implements CommentInterface
|
||||
{
|
||||
use CommentTableTypeTrait;
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_projects_comments')]
|
||||
#[ORM\Index(columns: ['project_id'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
class ProjectComment implements CommentInterface
|
||||
{
|
||||
use CommentTableTypeTrait;
|
||||
|
||||
@@ -113,11 +113,13 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
private ?string $avatar = null;
|
||||
/**
|
||||
* API token (password) for this user
|
||||
* @deprecated since 2.55
|
||||
*/
|
||||
#[ORM\Column(name: 'api_token', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $apiToken = null;
|
||||
/**
|
||||
* @internal to be set via form, must not be persisted
|
||||
* @deprecated since 2.55
|
||||
*/
|
||||
#[Assert\NotBlank(groups: ['ApiTokenUpdate'])]
|
||||
#[Assert\Length(min: 8, max: 60, groups: ['ApiTokenUpdate'])]
|
||||
@@ -298,11 +300,17 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.57
|
||||
*/
|
||||
public function getApiToken(): ?string
|
||||
{
|
||||
return $this->apiToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.57
|
||||
*/
|
||||
public function setApiToken(?string $apiToken): User
|
||||
{
|
||||
$this->apiToken = $apiToken;
|
||||
@@ -316,16 +324,23 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Serializer\VirtualProperty]
|
||||
#[Serializer\SerializedName('apiToken')]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(description: 'DEPRECATED - switch to API tokens instead', deprecated: true)]
|
||||
public function hasApiToken(): bool
|
||||
{
|
||||
return $this->apiToken !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.57
|
||||
*/
|
||||
public function getPlainApiToken(): ?string
|
||||
{
|
||||
return $this->plainApiToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.57
|
||||
*/
|
||||
public function setPlainApiToken(?string $plainApiToken): User
|
||||
{
|
||||
$this->plainApiToken = $plainApiToken;
|
||||
@@ -644,6 +659,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Use this function to check if the current user can read data from the given user.
|
||||
*
|
||||
* @deprecated since 2.57 use RolePermissionManager::checkUserAccess() or is_granted('access_user', user)
|
||||
*/
|
||||
public function canSeeUser(User $user): bool
|
||||
{
|
||||
@@ -651,7 +668,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->canSeeAllData()) {
|
||||
if ($this->isSuperAdmin() || $this->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -667,9 +684,21 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return true;
|
||||
}
|
||||
|
||||
// special case: the requested user is in no team and the current user is a teamlead.
|
||||
// this configuration is likely in new installations with small teams, and
|
||||
// it is allowed for teamleads to see other users data by definition
|
||||
if ($this->hasTeamleadRole() && $user->isRegularUserOnly()) {
|
||||
return \count($user->getTeams()) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isRegularUserOnly(): bool
|
||||
{
|
||||
return $this->getRoles() === [static::DEFAULT_ROLE];
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all teams, this user is part of
|
||||
*
|
||||
@@ -837,7 +866,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
$this->plainPassword = null;
|
||||
$this->plainApiToken = null;
|
||||
$this->plainApiToken = null; // @phpstan-ignore property.deprecated
|
||||
}
|
||||
|
||||
public function hasUsername(): bool
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use KevinPapst\TablerBundle\Helper\ContextHelper;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
@@ -25,7 +26,8 @@ final class ThemeOptionsSubscriber implements EventSubscriberInterface
|
||||
public function __construct(
|
||||
private readonly TokenStorageInterface $storage,
|
||||
private readonly ContextHelper $helper,
|
||||
private readonly LocaleService $localeService
|
||||
private readonly LocaleService $localeService,
|
||||
private readonly SystemConfiguration $systemConfiguration,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -48,18 +50,13 @@ final class ThemeOptionsSubscriber implements EventSubscriberInterface
|
||||
$this->helper->setIsRightToLeft(true);
|
||||
}
|
||||
|
||||
// ignore events like the toolbar where we do not have a token
|
||||
if (null === $this->storage->getToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->storage->getToken()->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
return;
|
||||
}
|
||||
$skin = $this->systemConfiguration->getAuthenticationTheme();
|
||||
|
||||
$user = $this->storage->getToken()?->getUser();
|
||||
if ($user instanceof User) {
|
||||
$skin = $user->getSkin();
|
||||
}
|
||||
|
||||
if ($skin === 'dark') {
|
||||
$this->helper->setIsDarkMode(true);
|
||||
$this->helper->setThemeAuto(false);
|
||||
|
||||
44
src/Form/API/CommentApiForm.php
Normal file
44
src/Form/API/CommentApiForm.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?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\Form\API;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class CommentApiForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('pinned', CheckboxType::class, [
|
||||
'required' => false,
|
||||
'documentation' => [
|
||||
'default' => false,
|
||||
'description' => 'Pinned comments always appear first'
|
||||
],
|
||||
]);
|
||||
|
||||
$builder->add('message', TextareaType::class, [
|
||||
'label' => false,
|
||||
'documentation' => [
|
||||
'description' => 'The actual comment (markdown is supported)'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'csrf_protection' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ use App\Utils\SearchTerm;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
|
||||
/**
|
||||
* @implements DataTransformerInterface<SearchTerm, string>
|
||||
*/
|
||||
final class SearchTermTransformer implements DataTransformerInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -62,18 +62,13 @@ trait FormTrait
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = $event->getData();
|
||||
$customer = \array_key_exists('customer', $data) && $data['customer'] !== '' ? $data['customer'] : null;
|
||||
$project = \array_key_exists('project', $data) && $data['project'] !== '' ? $data['project'] : $project;
|
||||
|
||||
$event->getForm()->add('project', ProjectType::class, array_merge($options, [
|
||||
'group_by' => null,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
|
||||
// is there a better way to prevent starting a record with a hidden project ?
|
||||
$project = \is_string($project) ? (int) $project : $project;
|
||||
$customer = \is_string($customer) ? (int) $customer : $customer;
|
||||
if ($isNew && \is_int($project)) {
|
||||
/** @var Project $project */
|
||||
$project = $repo->find($project);
|
||||
if ($project !== null) {
|
||||
if ($isNew && $project instanceof Project) {
|
||||
if (!$project->getCustomer()->isVisible()) {
|
||||
$customer = null;
|
||||
$project = null;
|
||||
@@ -81,11 +76,6 @@ trait FormTrait
|
||||
$project = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($project !== null && !\is_int($project) && !($project instanceof Project)) {
|
||||
throw new \InvalidArgumentException('Project type needs a project object or an ID');
|
||||
}
|
||||
|
||||
if ($customer !== null && !\is_int($customer) && !($customer instanceof Customer)) {
|
||||
throw new \InvalidArgumentException('Project type needs a customer object or an ID');
|
||||
|
||||
@@ -17,11 +17,14 @@ use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @template T of object
|
||||
*/
|
||||
final class MultiUpdateTable extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var EntityRepository $repository */
|
||||
/** @var EntityRepository<T> $repository */
|
||||
$repository = $options['repository'];
|
||||
/** @var MultiUpdateTableDTO $dto */
|
||||
$dto = $options['data'];
|
||||
|
||||
@@ -19,6 +19,9 @@ use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @implements DataTransformerInterface<string, string>
|
||||
*/
|
||||
final class ColorChoiceType extends AbstractType implements DataTransformerInterface
|
||||
{
|
||||
public function __construct(private readonly SystemConfiguration $systemConfiguration)
|
||||
|
||||
@@ -16,6 +16,9 @@ use Symfony\Component\Form\Extension\Core\Type\ColorType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @implements DataTransformerInterface<string, string>
|
||||
*/
|
||||
final class ColorPickerType extends AbstractType implements DataTransformerInterface
|
||||
{
|
||||
public const DEFAULT_COLOR = Constants::DEFAULT_COLOR;
|
||||
@@ -37,15 +40,21 @@ final class ColorPickerType extends AbstractType implements DataTransformerInter
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function transform(mixed $data): mixed
|
||||
{
|
||||
if (empty($data)) {
|
||||
if (!\is_string($data) || $data === '') {
|
||||
return self::DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function reverseTransform(mixed $value): mixed
|
||||
{
|
||||
return null === $value ? self::DEFAULT_COLOR : $value;
|
||||
|
||||
@@ -90,7 +90,11 @@ final class QuickEntryWeekType extends AbstractType
|
||||
}
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
|
||||
// exported entries cause the dropdown to be deactivated
|
||||
// we need to make sure to fetch the info before the field is replaced
|
||||
// see https://github.com/kimai/kimai/issues/5642
|
||||
$disabled = $event->getForm()->get('activity')->isDisabled();
|
||||
$event->getForm()->add('activity', ActivityType::class, array_merge(['disabled' => $disabled], $activityOptions));
|
||||
};
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ final class UserType extends AbstractType
|
||||
return $a->getDisplayName() <=> $b->getDisplayName();
|
||||
});
|
||||
|
||||
return array_values($userById);
|
||||
return $userById;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -97,11 +97,6 @@ class UserEditType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('systemAccount', YesNoType::class, [
|
||||
'label' => 'system_account',
|
||||
'help' => 'system_account.help',
|
||||
]);
|
||||
|
||||
if ($options['include_supervisor']) {
|
||||
$builder->add('supervisor', UserType::class, [
|
||||
'required' => false,
|
||||
@@ -111,6 +106,11 @@ class UserEditType extends AbstractType
|
||||
}
|
||||
|
||||
if ($options['include_password_reset']) {
|
||||
$builder->add('systemAccount', YesNoType::class, [
|
||||
'label' => 'system_account',
|
||||
'help' => 'system_account.help',
|
||||
]);
|
||||
|
||||
$builder->add('requiresPasswordReset', YesNoType::class, [
|
||||
'label' => 'force_password_change',
|
||||
'help' => 'force_password_change_help',
|
||||
|
||||
@@ -174,29 +174,19 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
||||
}
|
||||
}
|
||||
|
||||
$entries = $model->getEntries();
|
||||
$min = null;
|
||||
$max = null;
|
||||
$period = $model->getInvoicePeriod();
|
||||
$min = $period->getStart();
|
||||
$max = $period->getEnd();
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($min === null || $min->getBegin() > $entry->getBegin()) {
|
||||
$min = $entry;
|
||||
}
|
||||
|
||||
if ($max === null || $max->getBegin() < $entry->getBegin()) {
|
||||
$max = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if ($min !== null && $max !== null) {
|
||||
$values = array_merge($values, [
|
||||
'invoice.first' => $formatter->getFormattedDateTime($min->getBegin()),
|
||||
'invoice.first_process' => $min->getBegin()?->format(self::DATE_PROCESS_FORMAT), // since 2.14
|
||||
'invoice.last' => $formatter->getFormattedDateTime($max->getEnd()),
|
||||
'invoice.last_process' => $max->getEnd()?->format(self::DATE_PROCESS_FORMAT), // since 2.14
|
||||
return array_merge($values, [
|
||||
'invoice.first' => $formatter->getFormattedDateTime($min),
|
||||
'invoice.first_process' => $min->format(self::DATE_PROCESS_FORMAT), // since 2.14
|
||||
'invoice.first_month' => $formatter->getFormattedMonthName($min), // since 2.57
|
||||
'invoice.first_year' => $min->format('Y'), // since 2.57
|
||||
'invoice.last' => $formatter->getFormattedDateTime($max),
|
||||
'invoice.last_process' => $max->format(self::DATE_PROCESS_FORMAT), // since 2.14
|
||||
'invoice.last_month' => $formatter->getFormattedMonthName($max), // since 2.57
|
||||
'invoice.last_year' => $max->format('Y'), // since 2.57
|
||||
]);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,18 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($projects) === 0) {
|
||||
return [];
|
||||
$counter = \count($projects);
|
||||
|
||||
$values = [
|
||||
'project._counter' => $counter,
|
||||
];
|
||||
|
||||
if ($counter === 0) {
|
||||
return $values;
|
||||
}
|
||||
|
||||
$projects = array_values($projects);
|
||||
|
||||
$values = [];
|
||||
$i = 0;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
|
||||
@@ -100,6 +100,35 @@ final class InvoiceModel
|
||||
return $this->entries;
|
||||
}
|
||||
|
||||
public function getInvoicePeriod(): InvoicePeriod
|
||||
{
|
||||
$min = null;
|
||||
$max = null;
|
||||
|
||||
foreach ($this->getEntries() as $entry) {
|
||||
if ($min === null || $min > $entry->getBegin()) {
|
||||
$min = $entry->getBegin();
|
||||
}
|
||||
|
||||
if ($max === null || $max < $entry->getEnd()) {
|
||||
$max = $entry->getEnd();
|
||||
}
|
||||
}
|
||||
|
||||
if ($min === null) {
|
||||
$min = $this->getQuery()?->getBegin() ?? $this->invoiceDate;
|
||||
}
|
||||
|
||||
if ($max === null) {
|
||||
$max = $this->getQuery()?->getEnd() ?? $this->invoiceDate;
|
||||
}
|
||||
|
||||
return new InvoicePeriod(
|
||||
\DateTimeImmutable::createFromInterface($min),
|
||||
\DateTimeImmutable::createFromInterface($max)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $entries
|
||||
* @return InvoiceModel
|
||||
|
||||
29
src/Invoice/InvoicePeriod.php
Normal file
29
src/Invoice/InvoicePeriod.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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\Invoice;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final readonly class InvoicePeriod
|
||||
{
|
||||
public function __construct(private DateTimeInterface $start, private DateTimeInterface $end)
|
||||
{
|
||||
}
|
||||
|
||||
public function getStart(): DateTimeInterface
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function getEnd(): DateTimeInterface
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
|
||||
continue;
|
||||
}
|
||||
// we ONLY check if the given replacer content contains a formula character
|
||||
if (\is_string($content) && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
|
||||
if (\is_string($content) && $content !== '' && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
|
||||
$contentLooksLikeFormula = true;
|
||||
}
|
||||
$value = str_replace($searchKey, $content ?? '', $value);
|
||||
|
||||
@@ -73,10 +73,10 @@ class QuickEntryWeek
|
||||
$result = 0;
|
||||
} elseif ($aName === null && $bName !== null) {
|
||||
$result = 1;
|
||||
} elseif ($aName !== null && $bName === null) {
|
||||
} elseif ($aName !== null && $bName === null) { // @phpstan-ignore notIdentical.alwaysTrue
|
||||
$result = -1;
|
||||
} else {
|
||||
$result = strcmp((string) $aName, (string) $bName);
|
||||
$result = strcmp($aName, $bName);
|
||||
}
|
||||
|
||||
return $result < 0 ? -1 : 1;
|
||||
|
||||
@@ -198,4 +198,36 @@ final class RolePermissionManager
|
||||
|
||||
return $this->checkTeamLeadAccess($timesheet->getUser()?->getTeams() ?? [], $user);
|
||||
}
|
||||
|
||||
public function checkUserAccess(User $subject, User $user): bool
|
||||
{
|
||||
if ($subject->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user->isSuperAdmin() || $user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$subject->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$user->isSystemAccount() && $subject->isSystemAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isTeamleadOfUser($subject)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// special case: the requested user is in no team and the current user is a teamlead.
|
||||
// this configuration is likely in new installations with small teams, and
|
||||
// it is allowed for teamleads to see other users data by definition
|
||||
if (($user->hasTeamleadRole() || $user->isAdmin()) && $subject->isRegularUserOnly()) {
|
||||
return \count($subject->getTeams()) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Twig\Runtime;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Contracts\Service\ServiceSubscriberInterface;
|
||||
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Extension\RuntimeExtensionInterface;
|
||||
|
||||
final class EncoreExtension implements RuntimeExtensionInterface, ServiceSubscriberInterface
|
||||
@@ -32,12 +33,19 @@ final class EncoreExtension implements RuntimeExtensionInterface, ServiceSubscri
|
||||
|
||||
public function getEncoreEntryCssSource(string $packageName): string
|
||||
{
|
||||
if (!\in_array($packageName, ['invoice', 'invoice-pdf', 'export-pdf'])) {
|
||||
throw new RuntimeError('Unknown CSS package requested: ' . $packageName);
|
||||
}
|
||||
|
||||
$lookup = $this->container->get(EntrypointLookupInterface::class);
|
||||
$files = $lookup->getCssFiles($packageName);
|
||||
|
||||
$source = '';
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!str_ends_with($file, '.css') || str_contains($file, '..')) {
|
||||
continue;
|
||||
}
|
||||
$source .= file_get_contents($this->projectDirectory . '/public/' . $file);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ final class LocaleFormatter
|
||||
try {
|
||||
$date = new \DateTimeImmutable($date);
|
||||
} catch (Exception $ex) {
|
||||
return $date;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
src/Validator/Constraints/TimesheetTeamAccess.php
Normal file
29
src/Validator/Constraints/TimesheetTeamAccess.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class TimesheetTeamAccess extends TimesheetConstraint
|
||||
{
|
||||
public const PROJECT_ACCESS_ERROR = 'kimai-timesheet-team-project';
|
||||
public const ACTIVITY_ACCESS_ERROR = 'kimai-timesheet-team-activity';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::PROJECT_ACCESS_ERROR => 'You are not allowed to use this project.',
|
||||
self::ACTIVITY_ACCESS_ERROR => 'You are not allowed to use this activity.',
|
||||
];
|
||||
|
||||
public string $message = 'This timesheet has invalid settings.';
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
124
src/Validator/Constraints/TimesheetTeamAccessValidator.php
Normal file
124
src/Validator/Constraints/TimesheetTeamAccessValidator.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use App\Entity\User;
|
||||
use App\Security\RolePermissionManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetTeamAccessValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly RolePermissionManager $permissionManager,
|
||||
private readonly ManagerRegistry $registry,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof TimesheetTeamAccess)) {
|
||||
throw new UnexpectedTypeException($constraint, TimesheetTeamAccess::class);
|
||||
}
|
||||
|
||||
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
|
||||
throw new UnexpectedTypeException($value, TimesheetEntity::class);
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!($user instanceof User) || $user->canSeeAllData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$originalData = $this->getOriginalData($value);
|
||||
|
||||
$project = $value->getProject();
|
||||
if ($project !== null && $this->hasAssociationChanged($value, 'project', $project, $originalData)) {
|
||||
if (!$this->permissionManager->checkTeamAccessProject($project, $user)) {
|
||||
$this->context->buildViolation(TimesheetTeamAccess::getErrorName(TimesheetTeamAccess::PROJECT_ACCESS_ERROR))
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
$activity = $value->getActivity();
|
||||
if ($activity !== null && $this->hasAssociationChanged($value, 'activity', $activity, $originalData)) {
|
||||
if (!$this->permissionManager->checkTeamAccessActivity($activity, $user)) {
|
||||
$this->context->buildViolation(TimesheetTeamAccess::getErrorName(TimesheetTeamAccess::ACTIVITY_ACCESS_ERROR))
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetTeamAccess::ACTIVITY_ACCESS_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getOriginalData(TimesheetEntity $timesheet): array
|
||||
{
|
||||
if ($timesheet->getId() === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$manager = $this->registry->getManagerForClass(TimesheetEntity::class);
|
||||
if (!($manager instanceof EntityManagerInterface)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $manager->getUnitOfWork()->getOriginalEntityData($timesheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $originalData
|
||||
*/
|
||||
private function hasAssociationChanged(TimesheetEntity $timesheet, string $field, Project|Activity $current, array $originalData): bool
|
||||
{
|
||||
if ($timesheet->getId() === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!\array_key_exists($field, $originalData)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$original = $originalData[$field];
|
||||
|
||||
if ($original === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($original === $current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!\is_object($original) || !method_exists($original, 'getId')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($original->getId() === null || $current->getId() === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $original->getId() !== $current->getId();
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@ final class TimesheetVoter extends Voter
|
||||
self::EDIT_RATE,
|
||||
self::EDIT_EXPORT,
|
||||
'edit_billable',
|
||||
'duplicate'
|
||||
'duplicate',
|
||||
'is_owner',
|
||||
];
|
||||
|
||||
private ?bool $lockdownGrace = null;
|
||||
@@ -89,6 +90,9 @@ final class TimesheetVoter extends Voter
|
||||
$permission = '';
|
||||
|
||||
switch ($attribute) {
|
||||
case 'is_owner':
|
||||
return (!$subject instanceof MultiUserTimesheet) && $user === $subject->getUser();
|
||||
|
||||
case self::START:
|
||||
if (!$this->canStart($subject)) {
|
||||
return false;
|
||||
|
||||
@@ -75,7 +75,7 @@ final class UserVoter extends Voter
|
||||
}
|
||||
|
||||
if ($attribute === 'access_user') {
|
||||
return $user->canSeeUser($subject);
|
||||
return $this->permissionManager->checkUserAccess($subject, $user);
|
||||
}
|
||||
|
||||
if ($attribute === 'view_team_member') {
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
{% if comments is not null %}
|
||||
{% set options = {'form': commentForm, 'comments': comments} %}
|
||||
{% if can_edit %}
|
||||
{% set options = options|merge({'route_pin': 'customer_comment_pin', 'route_delete': 'customer_comment_delete'}) %}
|
||||
{% set options = options|merge({'route_pin': 'toggle_customer_comment_pin', 'route_api_options': {'id': customer.id}, 'route_delete': 'delete_customer_comment'}) %}
|
||||
{% endif %}
|
||||
{{ include('embeds/comments.html.twig', options) }}
|
||||
{% endif %}
|
||||
@@ -178,7 +178,7 @@
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.rateUpdate');
|
||||
KimaiReloadPageWidget.create('kimai.commentUpdate kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.rateUpdate');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% embed '@theme/embeds/card.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null), 'delete_by_user': delete_by_user|default(false)} %}
|
||||
{% embed '@theme/embeds/card.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null), 'route_api_options': route_api_options|default(null), 'delete_by_user': delete_by_user|default(false)} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ 'comment'|trans }}{% endblock %}
|
||||
{% block box_attributes %}id="comments_box"{% endblock %}
|
||||
@@ -26,12 +26,31 @@
|
||||
</div>
|
||||
<div class="col-auto align-self-center">
|
||||
{% if route_pin is not null %}
|
||||
<a href="{{ path(route_pin, {'id': comment.id, 'token': csrf_token('comment.pin')}) }}" class="btn{% if not comment.pinned %} btn-ghost-secondary{% else %} btn-ghost-info{% endif%} btn-icon {% if comment.pinned %}active{% endif %} pin-comment-link">{{ icon('pin') }}</a>
|
||||
{% if route_api_options is not null %}
|
||||
<a href="#" data-href="{{ path(route_pin, {'comment': comment.id}|merge(route_api_options)) }}" class="btn btn-action pin-comment-link api-link" data-event="kimai.commentUpdate" data-method="PATCH" data-msg-error="action.update.error">
|
||||
{% else %}
|
||||
<a href="{{ path(route_pin, {'id': comment.id, 'token': csrf_token('comment.pin')}) }}" class="btn btn-action pin-comment-link">
|
||||
{% endif %}
|
||||
|
||||
{% if comment.pinned %}
|
||||
<i class="text-primary {{ 'pin'|icon }}"></i>
|
||||
{% else %}
|
||||
{{ icon('pin') }}
|
||||
{% endif %}
|
||||
|
||||
</a>
|
||||
{% elseif comment.pinned %}
|
||||
{{ icon('pin') }}
|
||||
{% endif %}
|
||||
|
||||
{% if route_delete is not null and ((not delete_by_user) or (delete_by_user and comment.createdBy.id == app.user.id)) %}
|
||||
<a href="{{ path(route_delete, {'id': comment.id, 'token': csrf_token('comment.delete')}) }}" class="confirmation-link btn btn-ghost-secondary btn-icon delete-comment-link" data-question="confirm.delete">{{ icon('delete') }}</a>
|
||||
{% if route_api_options is not null %}
|
||||
<a href="#" data-href="{{ path(route_delete, {'comment': comment.id}|merge(route_api_options)) }}" class="btn btn-action pin-comment-link api-link" data-event="kimai.commentUpdate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error">
|
||||
{% else %}
|
||||
<a href="{{ path(route_delete, {'id': comment.id, 'token': csrf_token('comment.delete')}) }}" class="confirmation-link btn btn-action delete-comment-link" data-question="confirm.delete">
|
||||
{% endif %}
|
||||
{{ icon('delete') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<div class="dropdown-menu">
|
||||
{% for id, cfg in group %}
|
||||
{% set btnTitle = (cfg.title)|trans %}
|
||||
{% if loop.first %}
|
||||
{% if id == type %}
|
||||
{% set btnTitle = 'default'|trans %}
|
||||
{% endif %}
|
||||
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
|
||||
|
||||
@@ -74,14 +74,20 @@ mpdf-->
|
||||
<td class="text-right">
|
||||
{% set classLeft = 'text-left' %}
|
||||
{% set classRight = 'text-right text-nowrap padding-left' %}
|
||||
<table style="width: 240px">
|
||||
<table style="width: 270px">
|
||||
<tr>
|
||||
<td class="{{ classLeft }}">{{ 'date'|trans }}</td>
|
||||
<td class="{{ classRight }}">{{ invoice['invoice.date'] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="{{ classLeft }}">{{ 'invoice.service_date'|trans }}</td>
|
||||
<td class="{{ classRight }}">{{ invoice['query.end_month'] }} {{ invoice['query.end_year'] }}</td>
|
||||
<td class="{{ classRight }}">
|
||||
{% if invoice['invoice.first_month'] != invoice['invoice.last_month'] or invoice['invoice.first_year'] != invoice['invoice.last_year'] %}
|
||||
{{ invoice['invoice.first_month'] }} {{ invoice['invoice.first_year'] }}
|
||||
–
|
||||
{% endif %}
|
||||
{{ invoice['invoice.last_month'] }} {{ invoice['invoice.last_year'] }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="{{ classLeft }}">{{ 'invoice.number'|trans }}</td>
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
<tr>
|
||||
<th class="ps-0">{{ 'date'|trans }}</th>
|
||||
<td contenteditable="true">
|
||||
{% if invoice['query.begin_month_number'] != invoice['query.end_month_number'] or invoice['query.begin_year'] != invoice['query.end_year'] %}
|
||||
{{ invoice['query.begin'] }} - {{ invoice['query.end'] }}
|
||||
{% if invoice['invoice.first_month'] != invoice['invoice.last_month'] or invoice['invoice.first_year'] != invoice['invoice.last_year'] %}
|
||||
{{ invoice['invoice.first'] }} - {{ invoice['invoice.last'] }}
|
||||
{% else %}
|
||||
{{ invoice['query.end_month'] }} {{ invoice['query.end_year'] }}
|
||||
{{ invoice['invoice.last_month'] }} {{ invoice['invoice.last_year'] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
{% if comments is not null %}
|
||||
{% set options = {'form': commentForm, 'comments': comments} %}
|
||||
{% if can_edit %}
|
||||
{% set options = options|merge({'route_pin': 'project_comment_pin', 'route_delete': 'project_comment_delete'}) %}
|
||||
{% set options = options|merge({'route_pin': 'toggle_project_comment_pin', 'route_api_options': {'id': project.id}, 'route_delete': 'delete_project_comment'}) %}
|
||||
{% endif %}
|
||||
{{ include('embeds/comments.html.twig', options) }}
|
||||
{% endif %}
|
||||
@@ -176,7 +176,7 @@
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.projectTeamUpdate kimai.activityTeamUpdate kimai.projectUpdate kimai.teamUpdate kimai.customerUpdate kimai.rateUpdate');
|
||||
KimaiReloadPageWidget.create('kimai.commentUpdate kimai.customerTeamUpdate kimai.projectTeamUpdate kimai.activityTeamUpdate kimai.projectUpdate kimai.teamUpdate kimai.customerUpdate kimai.rateUpdate');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="font-weight-medium">
|
||||
<a href="{{ url }}" class="link-dark stretched-link text-reset">
|
||||
<a href="{{ url }}" class="stretched-link text-reset">
|
||||
{{ title|trans }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -325,6 +325,15 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
'metaFields' => ['result' => 'array', 'type' => 'InvoiceMeta'],
|
||||
];
|
||||
|
||||
case 'Comment':
|
||||
return [
|
||||
'id' => 'int',
|
||||
'message' => 'string',
|
||||
'createdBy' => ['result' => 'object', 'type' => '@User'],
|
||||
'createdAt' => '@datetime',
|
||||
'pinned' => 'bool',
|
||||
];
|
||||
|
||||
case 'PageActionItem':
|
||||
return [
|
||||
'id' => 'string',
|
||||
|
||||
@@ -73,6 +73,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/customers/{id}/meta',
|
||||
'/api/customers/{id}/rates',
|
||||
'/api/customers/{id}/rates/{rateId}',
|
||||
'/api/customers/{id}/comments',
|
||||
'/api/customers/{id}/comments/{comment}/pin',
|
||||
'/api/customers/{id}/comments/{comment}',
|
||||
'/api/export/{id}',
|
||||
'/api/invoices',
|
||||
'/api/invoices/{id}',
|
||||
@@ -83,6 +86,9 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
'/api/projects/{id}/meta',
|
||||
'/api/projects/{id}/rates',
|
||||
'/api/projects/{id}/rates/{rateId}',
|
||||
'/api/projects/{id}/comments',
|
||||
'/api/projects/{id}/comments/{comment}/pin',
|
||||
'/api/projects/{id}/comments/{comment}',
|
||||
'/api/ping',
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\API;
|
||||
use App\DataFixtures\UserFixtures;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerComment;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\Project;
|
||||
@@ -578,4 +579,299 @@ class CustomerControllerTest extends APIControllerBaseTestCase
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------- [COMMENTS] -------------------------------
|
||||
|
||||
private function createComment(string $message = 'A customer comment', bool $pinned = false, int $customerId = 1): CustomerComment
|
||||
{
|
||||
/** @var CustomerRepository $repository */
|
||||
$repository = $this->getEntityManager()->getRepository(Customer::class);
|
||||
/** @var Customer|null $customer */
|
||||
$customer = $repository->find($customerId);
|
||||
|
||||
self::assertInstanceOf(Customer::class, $customer);
|
||||
|
||||
$comment = new CustomerComment($customer);
|
||||
$comment->setMessage($message);
|
||||
$comment->setPinned($pinned);
|
||||
$comment->setCreatedBy($this->getUserByRole(User::ROLE_ADMIN));
|
||||
|
||||
$repository->saveComment($comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
public function testGetCommentsIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/customers/1/comments');
|
||||
}
|
||||
|
||||
public function testGetCommentsIsSecureForRole(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/customers/1/comments');
|
||||
}
|
||||
|
||||
public function testGetCommentsActionWithUnknownCustomer(): void
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_ADMIN, '/api/customers/' . PHP_INT_MAX . '/comments');
|
||||
}
|
||||
|
||||
public function testGetCommentsAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Visible comment', true);
|
||||
$this->request($client, '/api/customers/1/comments');
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
|
||||
);
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertCount(1, $result);
|
||||
self::assertIsArray($result[0]);
|
||||
self::assertApiResponseTypeStructure('Comment', $result[0]);
|
||||
|
||||
$first = $result[0];
|
||||
self::assertSame($comment->getId(), $first['id']);
|
||||
self::assertSame('Visible comment', $first['message']);
|
||||
self::assertTrue($first['pinned']);
|
||||
self::assertIsArray($first['createdBy']);
|
||||
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $first['createdBy']['id']);
|
||||
self::assertSame(UserFixtures::USERNAME_ADMIN, $first['createdBy']['username']);
|
||||
self::assertIsString($first['createdAt']);
|
||||
}
|
||||
|
||||
public function testPostCommentIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/customers/1/comments', Request::METHOD_POST);
|
||||
}
|
||||
|
||||
public function testPostCommentIsSecureForRole(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$json = json_encode(['message' => 'Denied']);
|
||||
self::assertIsString($json);
|
||||
|
||||
$this->request($client, '/api/customers/1/comments', Request::METHOD_POST, [], $json);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testPostCommentActionWithUnknownCustomer(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertEntityNotFoundForPost($client, '/api/customers/' . PHP_INT_MAX . '/comments', ['message' => 'Missing customer']);
|
||||
}
|
||||
|
||||
public function testPostCommentActionWithInvalidData(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'unexpected' => 'field',
|
||||
];
|
||||
|
||||
$json = json_encode($data);
|
||||
self::assertIsString($json);
|
||||
$this->request($client, '/api/customers/1/comments', Request::METHOD_POST, [], $json);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
|
||||
$this->assertApiCallValidationError($response, ['message'], true);
|
||||
}
|
||||
|
||||
public function testPostCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'message' => 'Created from API',
|
||||
'pinned' => true,
|
||||
];
|
||||
|
||||
$json = json_encode($data);
|
||||
self::assertIsString($json);
|
||||
$this->request($client, '/api/customers/1/comments', 'POST', [], $json);
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
|
||||
);
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertIsArray($result['createdBy']);
|
||||
self::assertIsInt($result['id']);
|
||||
self::assertNotEmpty($result['id']);
|
||||
self::assertSame('Created from API', $result['message']);
|
||||
self::assertTrue($result['pinned']);
|
||||
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $result['createdBy']['id']);
|
||||
|
||||
/** @var CustomerComment|null $comment */
|
||||
$comment = $this->getEntityManager()->getRepository(CustomerComment::class)->find($result['id']);
|
||||
self::assertInstanceOf(CustomerComment::class, $comment);
|
||||
self::assertSame('Created from API', $comment->getMessage());
|
||||
self::assertTrue($comment->isPinned());
|
||||
}
|
||||
|
||||
public function testToggleCommentPinIsSecure(): void
|
||||
{
|
||||
$comment = $this->createComment('Secured pin');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
|
||||
$client = self::createClient();
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_UNAUTHORIZED,
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testToggleCommentPinIsSecureForRole(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$comment = $this->createComment('Cannot pin');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testToggleCommentPinActionWithUnknownCustomer(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Pin me');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/customers/' . PHP_INT_MAX . '/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_NOT_FOUND,
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testToggleCommentPinActionWithUnknownComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->request($client, '/api/customers/1/comments/' . PHP_INT_MAX . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_NOT_FOUND,
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testToggleCommentPinActionDeniesForeignComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
[, $customer] = $this->loadCustomerData();
|
||||
$customerId = $customer->getId();
|
||||
self::assertNotNull($customerId);
|
||||
|
||||
$comment = $this->createComment('Foreign comment', false, $customerId);
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testToggleCommentPinAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Toggle me');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
|
||||
);
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertSame($comment->getId(), $result['id']);
|
||||
self::assertSame('Toggle me', $result['message']);
|
||||
self::assertTrue($result['pinned']);
|
||||
|
||||
/** @var CustomerComment|null $updated */
|
||||
$updated = $this->getEntityManager()->getRepository(CustomerComment::class)->find($comment->getId());
|
||||
self::assertInstanceOf(CustomerComment::class, $updated);
|
||||
self::assertTrue($updated->isPinned());
|
||||
}
|
||||
|
||||
public function testDeleteCommentIsSecure(): void
|
||||
{
|
||||
$comment = $this->createComment('Secured delete');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
|
||||
$client = self::createClient();
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_UNAUTHORIZED,
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testDeleteCommentIsSecureForRole(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$comment = $this->createComment('Cannot delete');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionWithUnknownCustomer(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Delete me later');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->assertNotFoundForDelete($client, '/api/customers/' . PHP_INT_MAX . '/comments/' . $comment->getId());
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionWithUnknownComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertNotFoundForDelete($client, '/api/customers/1/comments/' . PHP_INT_MAX);
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionDeniesForeignComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
[, $customer] = $this->loadCustomerData();
|
||||
$customerId = $customer->getId();
|
||||
self::assertNotNull($customerId);
|
||||
|
||||
$comment = $this->createComment('Foreign comment', false, $customerId);
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/customers/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testDeleteCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Delete me');
|
||||
self::assertNotNull($comment->getId());
|
||||
$commentId = $comment->getId();
|
||||
|
||||
$this->request($client, '/api/customers/1/comments/' . $commentId, Request::METHOD_DELETE);
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
self::assertSame(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
|
||||
self::assertEmpty($client->getResponse()->getContent());
|
||||
|
||||
self::assertNull($this->getEntityManager()->getRepository(CustomerComment::class)->find($commentId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\API;
|
||||
use App\DataFixtures\UserFixtures;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectComment;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\RateInterface;
|
||||
@@ -687,4 +688,299 @@ class ProjectControllerTest extends APIControllerBaseTestCase
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------- [COMMENTS] -------------------------------
|
||||
|
||||
private function createComment(string $message = 'A project comment', bool $pinned = false, int $projectId = 1): ProjectComment
|
||||
{
|
||||
/** @var ProjectRepository $repository */
|
||||
$repository = $this->getEntityManager()->getRepository(Project::class);
|
||||
/** @var Project|null $project */
|
||||
$project = $repository->find($projectId);
|
||||
|
||||
self::assertInstanceOf(Project::class, $project);
|
||||
|
||||
$comment = new ProjectComment($project);
|
||||
$comment->setMessage($message);
|
||||
$comment->setPinned($pinned);
|
||||
$comment->setCreatedBy($this->getUserByRole(User::ROLE_ADMIN));
|
||||
|
||||
$repository->saveComment($comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
public function testGetCommentsIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/projects/1/comments');
|
||||
}
|
||||
|
||||
public function testGetCommentsIsSecureForRole(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/projects/1/comments');
|
||||
}
|
||||
|
||||
public function testGetCommentsActionWithUnknownProject(): void
|
||||
{
|
||||
$this->assertEntityNotFound(User::ROLE_ADMIN, '/api/projects/' . PHP_INT_MAX . '/comments');
|
||||
}
|
||||
|
||||
public function testGetCommentsAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Visible comment', true);
|
||||
$this->request($client, '/api/projects/1/comments');
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
|
||||
);
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertCount(1, $result);
|
||||
self::assertIsArray($result[0]);
|
||||
self::assertApiResponseTypeStructure('Comment', $result[0]);
|
||||
|
||||
$first = $result[0];
|
||||
self::assertSame($comment->getId(), $first['id']);
|
||||
self::assertSame('Visible comment', $first['message']);
|
||||
self::assertTrue($first['pinned']);
|
||||
self::assertIsArray($first['createdBy']);
|
||||
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $first['createdBy']['id']);
|
||||
self::assertSame(UserFixtures::USERNAME_ADMIN, $first['createdBy']['username']);
|
||||
self::assertIsString($first['createdAt']);
|
||||
}
|
||||
|
||||
public function testPostCommentIsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/projects/1/comments', Request::METHOD_POST);
|
||||
}
|
||||
|
||||
public function testPostCommentIsSecureForRole(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$json = json_encode(['message' => 'Denied']);
|
||||
self::assertIsString($json);
|
||||
|
||||
$this->request($client, '/api/projects/1/comments', Request::METHOD_POST, [], $json);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testPostCommentActionWithUnknownProject(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertEntityNotFoundForPost($client, '/api/projects/' . PHP_INT_MAX . '/comments', ['message' => 'Missing project']);
|
||||
}
|
||||
|
||||
public function testPostCommentActionWithInvalidData(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'unexpected' => 'field',
|
||||
];
|
||||
|
||||
$json = json_encode($data);
|
||||
self::assertIsString($json);
|
||||
$this->request($client, '/api/projects/1/comments', Request::METHOD_POST, [], $json);
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
|
||||
$this->assertApiCallValidationError($response, ['message'], true);
|
||||
}
|
||||
|
||||
public function testPostCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$data = [
|
||||
'message' => 'Created from API',
|
||||
'pinned' => true,
|
||||
];
|
||||
|
||||
$json = json_encode($data);
|
||||
self::assertIsString($json);
|
||||
$this->request($client, '/api/projects/1/comments', 'POST', [], $json);
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
|
||||
);
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertIsArray($result['createdBy']);
|
||||
self::assertIsInt($result['id']);
|
||||
self::assertNotEmpty($result['id']);
|
||||
self::assertSame('Created from API', $result['message']);
|
||||
self::assertTrue($result['pinned']);
|
||||
self::assertSame($this->getAuthenticatedUserId(User::ROLE_ADMIN), $result['createdBy']['id']);
|
||||
|
||||
/** @var ProjectComment|null $comment */
|
||||
$comment = $this->getEntityManager()->getRepository(ProjectComment::class)->find($result['id']);
|
||||
self::assertInstanceOf(ProjectComment::class, $comment);
|
||||
self::assertSame('Created from API', $comment->getMessage());
|
||||
self::assertTrue($comment->isPinned());
|
||||
}
|
||||
|
||||
public function testToggleCommentPinIsSecure(): void
|
||||
{
|
||||
$comment = $this->createComment('Secured pin');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
|
||||
$client = self::createClient();
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_UNAUTHORIZED,
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testToggleCommentPinIsSecureForRole(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$comment = $this->createComment('Cannot pin');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testToggleCommentPinActionWithUnknownProject(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Pin me');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/projects/' . PHP_INT_MAX . '/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_NOT_FOUND,
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testToggleCommentPinActionWithUnknownComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->request($client, '/api/projects/1/comments/' . PHP_INT_MAX . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_NOT_FOUND,
|
||||
'message' => 'Not Found'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testToggleCommentPinActionDeniesForeignComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
[, $project] = $this->loadProjectTestData();
|
||||
$projectId = $project->getId();
|
||||
self::assertNotNull($projectId);
|
||||
|
||||
$comment = $this->createComment('Foreign comment', false, $projectId);
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testToggleCommentPinAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Toggle me');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId() . '/pin', Request::METHOD_PATCH);
|
||||
self::assertTrue(
|
||||
$client->getResponse()->isSuccessful(),
|
||||
$client->getResponse()->getStatusCode() . ' ' . (string) $client->getResponse()->getContent()
|
||||
);
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertSame($comment->getId(), $result['id']);
|
||||
self::assertSame('Toggle me', $result['message']);
|
||||
self::assertTrue($result['pinned']);
|
||||
|
||||
/** @var ProjectComment|null $updated */
|
||||
$updated = $this->getEntityManager()->getRepository(ProjectComment::class)->find($comment->getId());
|
||||
self::assertInstanceOf(ProjectComment::class, $updated);
|
||||
self::assertTrue($updated->isPinned());
|
||||
}
|
||||
|
||||
public function testDeleteCommentIsSecure(): void
|
||||
{
|
||||
$comment = $this->createComment('Secured delete');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
|
||||
$client = self::createClient();
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
|
||||
$this->assertApiException($client->getResponse(), [
|
||||
'code' => Response::HTTP_UNAUTHORIZED,
|
||||
'message' => 'Unauthorized'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testDeleteCommentIsSecureForRole(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$comment = $this->createComment('Cannot delete');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionWithUnknownProject(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Delete me later');
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->assertNotFoundForDelete($client, '/api/projects/' . PHP_INT_MAX . '/comments/' . $comment->getId());
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionWithUnknownComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertNotFoundForDelete($client, '/api/projects/1/comments/' . PHP_INT_MAX);
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionDeniesForeignComment(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
[, $project] = $this->loadProjectTestData();
|
||||
$projectId = $project->getId();
|
||||
self::assertNotNull($projectId);
|
||||
|
||||
$comment = $this->createComment('Foreign comment', false, $projectId);
|
||||
self::assertNotNull($comment->getId());
|
||||
|
||||
$this->request($client, '/api/projects/1/comments/' . $comment->getId(), Request::METHOD_DELETE);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testDeleteCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$comment = $this->createComment('Delete me');
|
||||
self::assertNotNull($comment->getId());
|
||||
$commentId = $comment->getId();
|
||||
|
||||
$this->request($client, '/api/projects/1/comments/' . $commentId, Request::METHOD_DELETE);
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
self::assertSame(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
|
||||
self::assertEmpty($client->getResponse()->getContent());
|
||||
|
||||
self::assertNull($this->getEntityManager()->getRepository(ProjectComment::class)->find($commentId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\TimesheetMeta;
|
||||
use App\Entity\User;
|
||||
@@ -24,6 +25,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\HttpKernelBrowser;
|
||||
|
||||
#[Group('integration')]
|
||||
class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
@@ -131,6 +133,68 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
|
||||
}
|
||||
|
||||
public function testGetCollectionForOtherUserDeniedWhenTeamleadIsOnlyPlainMemberOfOwnerTeam(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$sharedTeam = new Team('timesheet-list-shared');
|
||||
$sharedTeam->addUser($owner);
|
||||
$sharedTeam->addUser($teamlead);
|
||||
$em->persist($sharedTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
|
||||
$ownerId = $owner->getId();
|
||||
self::assertIsInt($ownerId);
|
||||
self::assertNotNull($timesheet->getId());
|
||||
|
||||
$this->request($client, '/api/timesheets', 'GET', ['user' => (string) $ownerId]);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets', 'GET', ['users' => [(string) $ownerId]]);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testGetCollectionForOtherUserAllowedWhenTeamleadOfOwnerTeam(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$sharedTeam = new Team('timesheet-list-teamlead');
|
||||
$sharedTeam->addUser($owner);
|
||||
$sharedTeam->addTeamlead($teamlead);
|
||||
$em->persist($sharedTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
|
||||
$ownerId = $owner->getId();
|
||||
self::assertIsInt($ownerId);
|
||||
self::assertNotNull($timesheet->getId());
|
||||
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', ['user' => (string) $ownerId]);
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertCount(1, $result);
|
||||
self::assertIsArray($result[0]);
|
||||
self::assertSame($ownerId, $result[0]['user']);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', ['users' => [(string) $ownerId]]);
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertCount(1, $result);
|
||||
self::assertIsArray($result[0]);
|
||||
self::assertSame($ownerId, $result[0]['user']);
|
||||
}
|
||||
|
||||
public function testGetCollectionForAllUserIsSecure(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
@@ -226,7 +290,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
|
||||
$begin = $factory->createDateTime('first day of this month');
|
||||
$begin = $begin->setTime(0, 0, 1);
|
||||
$begin = $begin->setTime(0, 0, 0);
|
||||
|
||||
$end = $factory->createDateTime('last day of this month');
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
@@ -283,7 +347,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
|
||||
$begin = $factory->create('first day of this month');
|
||||
$begin = $begin->setTime(0, 0, 1);
|
||||
$begin = $begin->setTime(0, 0, 0);
|
||||
|
||||
$end = $factory->create('last day of this month');
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
@@ -325,12 +389,12 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
$fixture = new TimesheetFixtures($user, 7);
|
||||
$fixture->setExported(true);
|
||||
$fixture->setStartDate(new \DateTime('first day of this month'));
|
||||
$fixture->setStartDate($factory->createDateTime('first day of this month 00:00:01'));
|
||||
$fixture->setAllowEmptyDescriptions(false);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$begin = $factory->create('first day of this month');
|
||||
$begin = $begin->setTime(0, 0, 1);
|
||||
$begin = $begin->setTime(0, 0, 0);
|
||||
|
||||
$end = $factory->create('last day of this month');
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
@@ -664,6 +728,39 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$this->assertApiCallValidationError($client->getResponse(), ['project']);
|
||||
}
|
||||
|
||||
public function testPostActionRejectsTeamRestrictedVisibleProjectOutsideUsersScope(): void
|
||||
{
|
||||
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
[$restrictedProject] = $this->createTeamRestrictedProjectFixture('post');
|
||||
$globalActivity = $this->getEntityManager()->getRepository(Activity::class)->find(1);
|
||||
self::assertInstanceOf(Activity::class, $globalActivity);
|
||||
self::assertNull($globalActivity->getProject(), 'Sanity check: fixture activity 1 must stay global for the POST PoC.');
|
||||
|
||||
$this->assertProjectIsHiddenFromApi($client, $restrictedProject);
|
||||
|
||||
$data = [
|
||||
'activity' => $globalActivity->getId(),
|
||||
'project' => $restrictedProject->getId(),
|
||||
'begin' => ($dateTime->createDateTime('-8 hours'))->format(self::DATE_FORMAT),
|
||||
'end' => ($dateTime->createDateTime())->format(self::DATE_FORMAT),
|
||||
'description' => 'GHSA-vrr2-create-attempt',
|
||||
];
|
||||
$json = json_encode($data);
|
||||
self::assertIsString($json);
|
||||
$this->request($client, '/api/timesheets', 'POST', [], $json);
|
||||
$this->assertApiCallValidationError($client->getResponse(), ['project' => 'The selected choice is invalid.']);
|
||||
|
||||
self::assertSame(
|
||||
0,
|
||||
$this->getEntityManager()->getRepository(Timesheet::class)->count([
|
||||
'user' => $owner,
|
||||
'description' => 'GHSA-vrr2-create-attempt',
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
// check for activity, as this is a required field. It will not be included in the select, as it is
|
||||
// already filtered within the repository due to the hidden flag
|
||||
public function testPostActionWithInvisibleActivity(): void
|
||||
@@ -852,6 +949,46 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
self::assertFalse($result['billable']);
|
||||
}
|
||||
|
||||
public function testPatchActionRejectsReassigningOwnTimesheetToTeamRestrictedVisibleProject(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$allowedProject = $em->getRepository(Project::class)->find(1);
|
||||
self::assertInstanceOf(Project::class, $allowedProject);
|
||||
$globalActivity = $em->getRepository(Activity::class)->find(1);
|
||||
self::assertInstanceOf(Activity::class, $globalActivity);
|
||||
self::assertNull($globalActivity->getProject(), 'Sanity check: fixture activity 1 must stay global for the PATCH PoC.');
|
||||
|
||||
$timesheet = $this->persistFinishedTimesheet($owner, $allowedProject, $globalActivity, 'GHSA-vrr2-patch-baseline');
|
||||
[$restrictedProject] = $this->createTeamRestrictedProjectFixture('patch');
|
||||
|
||||
$this->assertProjectIsHiddenFromApi($client, $restrictedProject);
|
||||
|
||||
$json = json_encode(['project' => $restrictedProject->getId()]);
|
||||
self::assertIsString($json);
|
||||
$this->request($client, '/api/timesheets/' . $timesheet->getId(), 'PATCH', [], $json);
|
||||
$this->assertApiCallValidationError($client->getResponse(), ['project' => 'The selected choice is invalid.']);
|
||||
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Timesheet::class)->find($timesheet->getId());
|
||||
self::assertInstanceOf(Timesheet::class, $reloaded);
|
||||
self::assertSame($allowedProject->getId(), $reloaded->getProject()?->getId());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $timesheet->getId(), 'GET', ['full' => 'true']);
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TimesheetEntity', $result);
|
||||
self::assertSame($allowedProject->getId(), $result['project']);
|
||||
self::assertNotSame($restrictedProject->getId(), $result['project']);
|
||||
}
|
||||
|
||||
public function testPatchActionWithInvalidUser(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
@@ -1513,4 +1650,453 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
$timesheet = $em->getRepository(Timesheet::class)->find($id);
|
||||
self::assertEquals('another,testing,bar', $timesheet->getMetaField('metatestmock')->getValue());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// CVE-2024-29200 / GHSA-cj3c-5xpm-cx94 — per-record IDOR regression suite.
|
||||
//
|
||||
// The list endpoint fix (TimesheetRepository::addPermissionCriteria) covers
|
||||
// GET /api/timesheets only. The per-record routes load a Timesheet by id
|
||||
// and rely entirely on TimesheetVoter for authorisation. Previously
|
||||
// the voter only asked "is this the caller's own entry?" — so a teamlead
|
||||
// with view_other_timesheet could read, mutate or delete any timesheet by
|
||||
// id, regardless of team scope. These tests pin the new team-scoped
|
||||
// behaviour (RolePermissionManager::checkTeamAccessTimesheet) on every
|
||||
// affected route.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function testCveIdorTeamleadCannotReachAnyPerRecordRouteWhenCustomerTeamRestricts(): void
|
||||
{
|
||||
// Direct reproduction of the security advisory's PoC. The customer
|
||||
// belongs to a team the teamlead is not in; the timesheet owner is
|
||||
// a different user; the teamlead must be denied on every per-record
|
||||
// route, not just on GET /api/timesheets.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$ownerTeam = new Team('owner team');
|
||||
$ownerTeam->addUser($owner);
|
||||
$em->persist($ownerTeam);
|
||||
|
||||
$customerTeam = new Team('customer team');
|
||||
$customerTeam->addUser($owner);
|
||||
$em->persist($customerTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
// 1) GET /api/timesheets/{id}
|
||||
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
|
||||
|
||||
// 2) PATCH /api/timesheets/{id}
|
||||
$patch = json_encode(['description' => 'HIJACKED_BY_BOB']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 3) PATCH /api/timesheets/{id}/stop and 4) GET .../stop
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 5) PATCH /api/timesheets/{id}/restart and 6) GET .../restart
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'GET');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 7) PATCH /api/timesheets/{id}/duplicate
|
||||
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 8) PATCH /api/timesheets/{id}/export
|
||||
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 9) PATCH /api/timesheets/{id}/meta
|
||||
$meta = json_encode(['name' => 'metatestmock', 'value' => 'pwned']);
|
||||
self::assertIsString($meta);
|
||||
$this->request($client, '/api/timesheets/' . $id . '/meta', 'PATCH', [], $meta);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// 10) DELETE /api/timesheets/{id} — verified last because it is destructive.
|
||||
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// The timesheet must still be in the database after every attempted attack.
|
||||
self::assertNotNull(
|
||||
$this->getEntityManager()->getRepository(Timesheet::class)->find($id),
|
||||
'PoC: DELETE leaked through and the row was removed from the database.'
|
||||
);
|
||||
}
|
||||
|
||||
public function testCveIdorTeamleadAsPlainMemberOfOwnerTeamCannotReachAnyPerRecordRoute(): void
|
||||
{
|
||||
// No customer/project/activity team restriction — only the owner is in
|
||||
// a team. The teamlead is a plain member of that same team. Plain
|
||||
// membership must NOT be enough to reach a foreign user's timesheet
|
||||
// via per-record routes (RolePermissionManager::checkTeamLeadAccess).
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$sharedTeam = new Team('shared');
|
||||
$sharedTeam->addUser($owner);
|
||||
$sharedTeam->addUser($teamlead); // plain member, not addTeamlead()
|
||||
$em->persist($sharedTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
|
||||
|
||||
$patch = json_encode(['description' => 'HIJACKED_BY_BOB']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$meta = json_encode(['name' => 'metatestmock', 'value' => 'pwned']);
|
||||
self::assertIsString($meta);
|
||||
$this->request($client, '/api/timesheets/' . $id . '/meta', 'PATCH', [], $meta);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testTeamleadOfOwnerTeamCanAccessTimesheetOnPerRecordRoutes(): void
|
||||
{
|
||||
// Positive control: when the teamlead is actually the teamlead of the
|
||||
// owner's team and there is no customer/project/activity restriction,
|
||||
// they pass the new team gate.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$sharedTeam = new Team('shared');
|
||||
$sharedTeam->addUser($owner);
|
||||
$sharedTeam->addTeamlead($teamlead);
|
||||
$em->persist($sharedTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
// GET — read access succeeds.
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets/' . $id);
|
||||
|
||||
// PATCH — mutation succeeds.
|
||||
$patch = json_encode(['description' => 'edited by teamlead']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'PATCH should succeed when teamlead is teamlead of owner team');
|
||||
|
||||
// /duplicate — succeeds (project + activity visible).
|
||||
$this->request($client, '/api/timesheets/' . $id . '/duplicate', 'PATCH');
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'duplicate should succeed for legitimate teamlead');
|
||||
|
||||
// /export — succeeds, ROLE_TEAMLEAD has edit_export_other_timesheet.
|
||||
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'export should succeed for legitimate teamlead');
|
||||
}
|
||||
|
||||
public function testCustomerTeamRestrictionStillBlocksLegitimateTeamleadOfOwnerTeam(): void
|
||||
{
|
||||
// Even with the teamlead being the teamlead of the owner's team, the
|
||||
// customer-level team gate must still apply. A teamlead may not bypass
|
||||
// a customer's team restriction just because they happen to lead the
|
||||
// owner's team.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$ownerTeam = new Team('owner team');
|
||||
$ownerTeam->addUser($owner);
|
||||
$ownerTeam->addTeamlead($teamlead);
|
||||
$em->persist($ownerTeam);
|
||||
|
||||
// Customer team has only the owner; the teamlead is NOT a member.
|
||||
$customerTeam = new Team('customer team');
|
||||
$customerTeam->addUser($owner);
|
||||
$em->persist($customerTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
|
||||
|
||||
$patch = json_encode(['description' => 'should not work']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
|
||||
public function testOwnerCanAlwaysAccessOwnTimesheetEvenWithRestrictiveTeams(): void
|
||||
{
|
||||
// Owner short-circuit: the team gate must NOT apply when the caller is
|
||||
// also the timesheet's user. Even a customer team locked to other
|
||||
// users plus an owner-only team must not prevent self-access.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$customerTeam = new Team('customer team excluding owner');
|
||||
// owner is NOT a member of the customer team — checkTeamAccessProject
|
||||
// would normally deny. Owner short-circuit must bypass it.
|
||||
$em->persist($customerTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets/' . $id);
|
||||
|
||||
$patch = json_encode(['description' => 'self edit']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'Owner must be able to edit own timesheet');
|
||||
}
|
||||
|
||||
public function testSuperAdminCanAccessTimesheetDespiteRestrictiveTeams(): void
|
||||
{
|
||||
// canSeeAllData via isSuperAdmin() — bypasses every team gate, on
|
||||
// every per-record route.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$ownerTeam = new Team('owner team');
|
||||
$ownerTeam->addUser($owner);
|
||||
$em->persist($ownerTeam);
|
||||
|
||||
$customerTeam = new Team('customer team excluding super admin');
|
||||
$customerTeam->addUser($owner);
|
||||
$em->persist($customerTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets/' . $id);
|
||||
|
||||
$patch = json_encode(['description' => 'super admin edit']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
self::assertTrue($client->getResponse()->isSuccessful(), 'SUPER_ADMIN must be able to edit any timesheet');
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
|
||||
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testCveIdorOnRunningTimesheetStopRoutesAreBlocked(): void
|
||||
{
|
||||
// /stop targets a running timesheet. Verifies that even when the route
|
||||
// would otherwise be functional, the team gate denies access.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$customerTeam = new Team('customer team');
|
||||
$customerTeam->addUser($owner);
|
||||
$em->persist($customerTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: true);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
self::assertNull($timesheet->getEnd(), 'sanity: timesheet must be running for /stop');
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id . '/stop', 'GET');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// Confirm side-effect-free: timesheet must still be running.
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Timesheet::class)->find($id);
|
||||
self::assertInstanceOf(Timesheet::class, $reloaded);
|
||||
self::assertNull($reloaded->getEnd(), '/stop must not have stopped the timesheet behind the team gate');
|
||||
}
|
||||
|
||||
public function testTeamleadFromUnrelatedTeamCannotAccessTimesheetById(): void
|
||||
{
|
||||
// Mirrors the "bob-from-TeamB attacks alice-in-TeamA" PoC from the
|
||||
// advisory: both users have teams, but those teams are completely
|
||||
// unrelated. The attacker happens to be a teamlead of his own team.
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$teamA = new Team('TeamA — owner only');
|
||||
$teamA->addUser($owner);
|
||||
|
||||
$teamB = new Team('TeamB — attacker only');
|
||||
$teamB->addTeamlead($teamlead);
|
||||
|
||||
$customerTeam = new Team('customer team — TeamA scope');
|
||||
$customerTeam->addUser($owner);
|
||||
|
||||
$em->persist($teamA);
|
||||
$em->persist($teamB);
|
||||
$em->persist($customerTeam);
|
||||
|
||||
$timesheet = $this->persistRestrictedTimesheet($owner, [$customerTeam], running: false);
|
||||
$id = $timesheet->getId();
|
||||
self::assertIsInt($id);
|
||||
|
||||
$this->assertApiAccessDenied($client, '/api/timesheets/' . $id);
|
||||
|
||||
$patch = json_encode(['description' => 'HIJACKED_BY_BOB']);
|
||||
self::assertIsString($patch);
|
||||
$this->request($client, '/api/timesheets/' . $id, 'PATCH', [], $patch);
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
|
||||
$this->assertApiResponseAccessDenied($client->getResponse());
|
||||
|
||||
// Description must not have been mutated.
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(Timesheet::class)->find($id);
|
||||
self::assertInstanceOf(Timesheet::class, $reloaded);
|
||||
self::assertSame('ALICE_SECRET', $reloaded->getDescription(), 'PATCH leaked through and rewrote the description.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Team> $customerTeams teams to attach to the customer (= the project's customer)
|
||||
*/
|
||||
private function persistRestrictedTimesheet(User $owner, array $customerTeams, bool $running): Timesheet
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$customer = new Customer('CVE-2024-29200 customer');
|
||||
$customer->setCountry('DE');
|
||||
$customer->setTimezone(self::TEST_TIMEZONE);
|
||||
$customer->setVisible(true);
|
||||
foreach ($customerTeams as $team) {
|
||||
$customer->addTeam($team);
|
||||
}
|
||||
$em->persist($customer);
|
||||
|
||||
$project = new Project();
|
||||
$project->setName('CVE-2024-29200 project');
|
||||
$project->setCustomer($customer);
|
||||
$project->setVisible(true);
|
||||
$em->persist($project);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setName('CVE-2024-29200 activity');
|
||||
$activity->setProject($project);
|
||||
$activity->setVisible(true);
|
||||
$em->persist($activity);
|
||||
|
||||
// Flush the catalog entities first so they have ids before any Doctrine
|
||||
// subscriber tries to query them while persisting the timesheet
|
||||
// (RateService re-loads the activity inside the timesheet onFlush hook).
|
||||
$em->flush();
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($owner);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
$timesheet->setBegin(new \DateTime('-2 hours'));
|
||||
$timesheet->setDescription('ALICE_SECRET');
|
||||
if (!$running) {
|
||||
$end = new \DateTime('-1 hour');
|
||||
$timesheet->setEnd($end);
|
||||
$timesheet->setDuration(3600);
|
||||
}
|
||||
$em->persist($timesheet);
|
||||
$em->flush();
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Project, 1: Activity}
|
||||
*/
|
||||
private function createTeamRestrictedProjectFixture(string $suffix): array
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$restrictedTeam = new Team('GHSA-vrr2 team ' . $suffix);
|
||||
$restrictedTeam->addUser($this->getUserByRole(User::ROLE_TEAMLEAD));
|
||||
$em->persist($restrictedTeam);
|
||||
|
||||
$customer = new Customer('GHSA-vrr2 customer ' . $suffix);
|
||||
$customer->setCountry('DE');
|
||||
$customer->setCurrency('CHF');
|
||||
$customer->setTimezone(self::TEST_TIMEZONE);
|
||||
$customer->setVisible(true);
|
||||
$customer->addTeam($restrictedTeam);
|
||||
$em->persist($customer);
|
||||
|
||||
$project = new Project();
|
||||
$project->setName('GHSA-vrr2 project ' . $suffix);
|
||||
$project->setCustomer($customer);
|
||||
$project->setVisible(true);
|
||||
$em->persist($project);
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setName('GHSA-vrr2 activity ' . $suffix);
|
||||
$activity->setProject($project);
|
||||
$activity->setVisible(true);
|
||||
$em->persist($activity);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return [$project, $activity];
|
||||
}
|
||||
|
||||
private function assertProjectIsHiddenFromApi(HttpKernelBrowser $client, Project $project): void
|
||||
{
|
||||
$this->assertAccessIsGranted($client, '/api/projects');
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
self::assertIsArray($result);
|
||||
self::assertNotContains($project->getId(), array_column($result, 'id'));
|
||||
}
|
||||
|
||||
private function persistFinishedTimesheet(User $owner, Project $project, Activity $activity, string $description): Timesheet
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($owner);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setActivity($activity);
|
||||
$timesheet->setBegin(new \DateTime('-2 hours'));
|
||||
$timesheet->setEnd(new \DateTime('-1 hour'));
|
||||
$timesheet->setDuration(3600);
|
||||
$timesheet->setDescription($description);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($timesheet);
|
||||
$em->flush();
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,15 @@ namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\ActivityRate;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Role;
|
||||
use App\Entity\RolePermission;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\ActivityFixtures;
|
||||
use App\Tests\DataFixtures\CustomerFixtures;
|
||||
use App\Tests\DataFixtures\ProjectFixtures;
|
||||
use App\Tests\DataFixtures\TeamFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use App\Tests\Mocks\ActivityTestMetaFieldSubscriberMock;
|
||||
@@ -191,6 +196,54 @@ class ActivityControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('123.45', $node->text(null, true));
|
||||
}
|
||||
|
||||
public function testEditRateActionDeniesForeignRate(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$project = $this->getEntityManager()->getRepository(Project::class)->find(1);
|
||||
self::assertInstanceOf(Project::class, $project);
|
||||
|
||||
$activity = $this->importFixture((new ActivityFixtures(1))->setProjects([$project]))[0];
|
||||
$rate = new ActivityRate();
|
||||
$rate->setActivity($activity);
|
||||
$rate->setRate(123.45);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($rate);
|
||||
$em->flush();
|
||||
|
||||
$this->request($client, '/admin/activity/1/rate/' . $rate->getId());
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
}
|
||||
|
||||
public function testCreateWithProjectActionDeniesUserWithoutEditProjectPermission(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$customer = $this->importFixture(new CustomerFixtures(1))[0];
|
||||
$project = $this->importFixture((new ProjectFixtures(1))->setCustomers([$customer]))[0];
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$role = (new Role())->setName('TEST_CREATE_ACTIVITY_ONLY');
|
||||
$permission = (new RolePermission())->setRole($role)->setPermission('create_activity')->setAllowed(true);
|
||||
|
||||
$roleName = $role->getName();
|
||||
self::assertNotNull($roleName);
|
||||
$user->addRole($roleName);
|
||||
|
||||
$em->persist($role);
|
||||
$em->persist($permission);
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$this->request($client, '/admin/activity/create/' . $project->getId());
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
}
|
||||
|
||||
public function testCreateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerComment;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\CustomerFixtures;
|
||||
@@ -175,6 +175,24 @@ class CustomerControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('123.45', $node->text(null, true));
|
||||
}
|
||||
|
||||
public function testEditRateActionDeniesForeignRate(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$customer = $this->importFixture(new CustomerFixtures(1))[0];
|
||||
$rate = new CustomerRate();
|
||||
$rate->setCustomer($customer);
|
||||
$rate->setRate(123.45);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($rate);
|
||||
$em->flush();
|
||||
|
||||
$this->request($client, '/admin/customer/1/rate/' . $rate->getId());
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
}
|
||||
|
||||
public function testAddCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
@@ -198,78 +216,6 @@ class CustomerControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
|
||||
}
|
||||
|
||||
public function testDeleteCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
|
||||
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
|
||||
$client->submit($form, [
|
||||
'customer_comment_form' => [
|
||||
'message' => 'Blah foo bar',
|
||||
]
|
||||
]);
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
|
||||
$client->followRedirect();
|
||||
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
|
||||
self::assertStringContainsString('Blah foo bar', $node->html());
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.delete-comment-link');
|
||||
|
||||
$this->request($client, $node->attr('href'));
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
|
||||
self::assertStringContainsString('There were no comments posted yet', $node->html());
|
||||
}
|
||||
|
||||
public function testDeleteCommentActionWithoutToken(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
|
||||
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
|
||||
$client->submit($form, [
|
||||
'customer_comment_form' => [
|
||||
'message' => 'Blah foo bar',
|
||||
]
|
||||
]);
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
|
||||
$client->followRedirect();
|
||||
|
||||
$comments = $this->getEntityManager()->getRepository(CustomerComment::class)->findAll();
|
||||
$id = $comments[0]->getId();
|
||||
|
||||
$this->request($client, '/admin/customer/' . $id . '/comment_delete');
|
||||
|
||||
$this->assertRouteNotFound($client);
|
||||
}
|
||||
|
||||
public function testPinCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
|
||||
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
|
||||
$client->submit($form, [
|
||||
'customer_comment_form' => [
|
||||
'message' => 'Blah foo bar',
|
||||
]
|
||||
]);
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
|
||||
self::assertStringContainsString('Blah foo bar', $node->html());
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
|
||||
self::assertEquals(0, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link');
|
||||
self::assertEquals(1, $node->count());
|
||||
$this->request($client, $node->attr('href'));
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
|
||||
self::assertEquals(1, $node->count());
|
||||
self::assertStringContainsString('/admin/customer/', $node->attr('href'));
|
||||
self::assertStringContainsString('/comment_pin/', $node->attr('href'));
|
||||
}
|
||||
|
||||
public function testCreateDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookmarkRepository;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use App\Timesheet\FavoriteRecordService;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
|
||||
#[Group('integration')]
|
||||
@@ -41,4 +43,119 @@ class FavoriteControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('<a class="api-link text-decoration-none text-body d-block" href="/api/timesheets/', $content);
|
||||
self::assertStringContainsString('data-event="kimai.timesheetStart kimai.timesheetUpdate" data-method="PATCH" data-msg-error="timesheet', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the security issue in FavoriteController::add():
|
||||
* an unprivileged user must NOT be able to add a favorite for a timesheet
|
||||
* owned by another user, even if they know a valid timesheet ID.
|
||||
*/
|
||||
public function testAddFavoriteForOtherUsersTimesheetIsDenied(): void
|
||||
{
|
||||
// attacker is a plain user (ROLE_USER), not the timesheet owner
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$victim = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
$fixture = new TimesheetFixtures();
|
||||
$fixture->setAmount(1);
|
||||
$fixture->setUser($victim);
|
||||
$timesheets = $this->importFixture($fixture);
|
||||
$timesheetId = $timesheets[0]->getId();
|
||||
self::assertNotNull($timesheetId);
|
||||
|
||||
$this->request($client, '/favorite/timesheet/add/' . $timesheetId);
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
|
||||
// the victim's bookmark must not have been touched
|
||||
/** @var BookmarkRepository $bookmarkRepository */
|
||||
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
|
||||
$this->getEntityManager()->clear();
|
||||
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_TEAMLEAD), 'favorite', 'recent');
|
||||
if ($bookmark !== null) {
|
||||
self::assertNotContains($timesheetId, $bookmark->getContent(), 'attacker must not write to the victim\'s bookmark');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the security issue in FavoriteController::remove():
|
||||
* an unprivileged user must NOT be able to remove a favorite from another
|
||||
* user's bookmark, even if they know a valid timesheet ID.
|
||||
*/
|
||||
public function testRemoveFavoriteForOtherUsersTimesheetIsDenied(): void
|
||||
{
|
||||
// attacker is a plain user (ROLE_USER), not the timesheet owner
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$victim = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
$fixture = new TimesheetFixtures();
|
||||
$fixture->setAmount(1);
|
||||
$fixture->setUser($victim);
|
||||
$timesheets = $this->importFixture($fixture);
|
||||
$timesheet = $timesheets[0];
|
||||
$timesheetId = $timesheet->getId();
|
||||
self::assertNotNull($timesheetId);
|
||||
|
||||
// legitimately seed the victim's own favorites
|
||||
/** @var FavoriteRecordService $favoriteRecordService */
|
||||
$favoriteRecordService = $this->getPrivateService(FavoriteRecordService::class);
|
||||
$favoriteRecordService->addFavorite($timesheet);
|
||||
|
||||
/** @var BookmarkRepository $bookmarkRepository */
|
||||
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
|
||||
$this->getEntityManager()->clear();
|
||||
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_TEAMLEAD), 'favorite', 'recent');
|
||||
self::assertNotNull($bookmark);
|
||||
self::assertContains($timesheetId, $bookmark->getContent(), 'precondition: favorite exists for the victim');
|
||||
|
||||
// attacker (ROLE_USER) attempts to remove the favorite from the victim's bookmark
|
||||
$this->request($client, '/favorite/timesheet/remove/' . $timesheetId);
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
|
||||
// the victim's favorite must still be there
|
||||
/** @var BookmarkRepository $bookmarkRepository */
|
||||
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
|
||||
$this->getEntityManager()->clear();
|
||||
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_TEAMLEAD), 'favorite', 'recent');
|
||||
self::assertNotNull($bookmark);
|
||||
self::assertContains($timesheetId, $bookmark->getContent(), 'attacker must not remove the victim\'s favorite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the added `#[IsGranted('view', 'timesheet')]` voter does not
|
||||
* break the legitimate use case: a user managing favorites for their own
|
||||
* timesheet.
|
||||
*/
|
||||
public function testAddAndRemoveFavoriteForOwnTimesheetIsAllowed(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$owner = $this->getUserByRole(User::ROLE_USER);
|
||||
$fixture = new TimesheetFixtures();
|
||||
$fixture->setAmount(1);
|
||||
$fixture->setUser($owner);
|
||||
$timesheets = $this->importFixture($fixture);
|
||||
$timesheetId = $timesheets[0]->getId();
|
||||
self::assertNotNull($timesheetId);
|
||||
|
||||
$this->request($client, '/favorite/timesheet/add/' . $timesheetId);
|
||||
self::assertTrue($client->getResponse()->isRedirect());
|
||||
|
||||
/** @var BookmarkRepository $bookmarkRepository */
|
||||
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
|
||||
$this->getEntityManager()->clear();
|
||||
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_USER), 'favorite', 'recent');
|
||||
self::assertNotNull($bookmark);
|
||||
self::assertContains($timesheetId, $bookmark->getContent());
|
||||
|
||||
$this->request($client, '/favorite/timesheet/remove/' . $timesheetId);
|
||||
self::assertTrue($client->getResponse()->isRedirect());
|
||||
|
||||
/** @var BookmarkRepository $bookmarkRepository */
|
||||
$bookmarkRepository = $this->getPrivateService(BookmarkRepository::class);
|
||||
$this->getEntityManager()->clear();
|
||||
$bookmark = $bookmarkRepository->findBookmark($this->getUserByRole(User::ROLE_USER), 'favorite', 'recent');
|
||||
self::assertNotNull($bookmark);
|
||||
self::assertNotContains($timesheetId, $bookmark->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,13 @@ use App\Entity\ActivityRate;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\Role;
|
||||
use App\Entity\RolePermission;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DataFixtures\ActivityFixtures;
|
||||
use App\Tests\DataFixtures\CustomerFixtures;
|
||||
use App\Tests\DataFixtures\ProjectFixtures;
|
||||
use App\Tests\DataFixtures\TeamFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
@@ -212,6 +215,24 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertAddRate($client, 123.45, 1);
|
||||
}
|
||||
|
||||
public function testEditRateActionDeniesForeignRate(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$project = $this->importFixture(new ProjectFixtures(1))[0];
|
||||
$rate = new ProjectRate();
|
||||
$rate->setProject($project);
|
||||
$rate->setRate(123.45);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($rate);
|
||||
$em->flush();
|
||||
|
||||
$this->request($client, '/admin/project/1/rate/' . $rate->getId());
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
}
|
||||
|
||||
public function assertAddRate(HttpKernelBrowser $client, $rate, $projectId): void
|
||||
{
|
||||
$this->assertAccessIsGranted($client, '/admin/project/' . $projectId . '/rate');
|
||||
@@ -310,57 +331,6 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
|
||||
}
|
||||
|
||||
public function testDeleteCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/project/1/details');
|
||||
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
|
||||
$client->submit($form, [
|
||||
'project_comment_form' => [
|
||||
'message' => 'Foo bar blub',
|
||||
]
|
||||
]);
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
|
||||
self::assertStringContainsString('Foo bar blub', $node->html());
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.delete-comment-link');
|
||||
|
||||
$this->request($client, $node->attr('href'));
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
|
||||
self::assertStringContainsString('There were no comments posted yet', $node->html());
|
||||
}
|
||||
|
||||
public function testPinCommentAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/project/1/details');
|
||||
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
|
||||
$client->submit($form, [
|
||||
'project_comment_form' => [
|
||||
'message' => 'Foo bar blub',
|
||||
]
|
||||
]);
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body');
|
||||
self::assertStringContainsString('Foo bar blub', $node->html());
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
|
||||
self::assertEquals(0, $node->count());
|
||||
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link');
|
||||
self::assertEquals(1, $node->count());
|
||||
$this->request($client, $node->attr('href'));
|
||||
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
|
||||
$client->followRedirect();
|
||||
$node = $client->getCrawler()->filter('div.card#comments_box .card-body a.pin-comment-link.active');
|
||||
self::assertEquals(1, $node->count());
|
||||
self::assertStringContainsString('/admin/project/', $node->attr('href'));
|
||||
self::assertStringContainsString('/comment_pin/', $node->attr('href'));
|
||||
}
|
||||
|
||||
public function testCreateDefaultTeamAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
@@ -403,6 +373,32 @@ class ProjectControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertEquals(5, $node->count());
|
||||
}
|
||||
|
||||
public function testCreateWithCustomerActionDeniesUserWithoutEditCustomerPermission(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
|
||||
$customer = $this->importFixture(new CustomerFixtures(1))[0];
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$role = (new Role())->setName('TEST_CREATE_PROJECT_ONLY');
|
||||
$permission = (new RolePermission())->setRole($role)->setPermission('create_project')->setAllowed(true);
|
||||
|
||||
$roleName = $role->getName();
|
||||
self::assertNotNull($roleName);
|
||||
$user->addRole($roleName);
|
||||
|
||||
$em->persist($role);
|
||||
$em->persist($permission);
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
$this->request($client, '/admin/project/create/' . $customer->getId());
|
||||
|
||||
$this->assertAccessDenied($client);
|
||||
}
|
||||
|
||||
public function testCreateAction(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -142,8 +142,11 @@ class CustomerServiceTest extends TestCase
|
||||
$sut->saveCustomer($Customer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure(\DateTimeInterface): string $expected
|
||||
*/
|
||||
#[DataProvider('getTestData')]
|
||||
public function testCustomerNumber(string $format, int|string $expected): void
|
||||
public function testCustomerNumber(string $format, \Closure $expected): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'defaults' => [
|
||||
@@ -159,64 +162,64 @@ class CustomerServiceTest extends TestCase
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
|
||||
$date = new \DateTimeImmutable();
|
||||
$customer = $sut->createNewCustomer('Test');
|
||||
|
||||
self::assertEquals((string) $expected, $customer->getNumber());
|
||||
self::assertEquals($expected($date), $customer->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, string|\DateTime|int>>
|
||||
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
|
||||
*/
|
||||
public static function getTestData(): array
|
||||
{
|
||||
$dateTime = new \DateTime();
|
||||
|
||||
$yearLong = (int) $dateTime->format('Y');
|
||||
$yearShort = (int) $dateTime->format('y');
|
||||
$monthLong = $dateTime->format('m');
|
||||
$monthShort = (int) $dateTime->format('n');
|
||||
$dayLong = $dateTime->format('d');
|
||||
$dayShort = (int) $dateTime->format('j');
|
||||
$literal = static fn (string $value): \Closure => static fn (): string => $value;
|
||||
$date = static fn (string $format): \Closure => static fn (\DateTimeInterface $d): string => $d->format($format);
|
||||
$yearLong = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('Y') + $add);
|
||||
$yearShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('y') + $add);
|
||||
$monthShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('m') + $add);
|
||||
$dayShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('d') + $add);
|
||||
|
||||
return [
|
||||
// simple tests for single calls
|
||||
['{cc,1}', '2'],
|
||||
['{cc,2}', '02'],
|
||||
['{cc,3}', '002'],
|
||||
['{cc,4}', '0002'],
|
||||
['{Y}', $yearLong],
|
||||
['{y}', $yearShort],
|
||||
['{M}', $monthLong],
|
||||
['{m}', $monthShort],
|
||||
['{D}', $dayLong],
|
||||
['{d}', $dayShort],
|
||||
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
|
||||
['{Y,6}', '00' . $yearLong],
|
||||
['{M,3}', '0' . $monthLong],
|
||||
['{D,3}', '0' . $dayLong],
|
||||
['{cc,1}', $literal('2')],
|
||||
['{cc,2}', $literal('02')],
|
||||
['{cc,3}', $literal('002')],
|
||||
['{cc,4}', $literal('0002')],
|
||||
['{Y}', $date('Y')],
|
||||
['{y}', $date('y')],
|
||||
['{M}', $date('m')],
|
||||
['{m}', $date('n')],
|
||||
['{D}', $date('d')],
|
||||
['{d}', $date('j')],
|
||||
// number formatting
|
||||
['{Y,6}', static fn (\DateTimeInterface $d): string => '00' . $d->format('Y')],
|
||||
['{M,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('m')],
|
||||
['{D,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('d')],
|
||||
// increment dates
|
||||
['{YY}', $yearLong + 1],
|
||||
['{YY+1}', $yearLong + 1],
|
||||
['{YY+2}', $yearLong + 2],
|
||||
['{YY+3}', $yearLong + 3],
|
||||
['{YY-1}', $yearLong - 1],
|
||||
['{YY-2}', $yearLong - 2],
|
||||
['{YY-3}', $yearLong - 3],
|
||||
['{yy}', $yearShort + 1],
|
||||
['{yy+1}', $yearShort + 1],
|
||||
['{yy+2}', $yearShort + 2],
|
||||
['{yy+3}', $yearShort + 3],
|
||||
['{yy-1}', $yearShort - 1],
|
||||
['{yy-2}', $yearShort - 2],
|
||||
['{yy-3}', $yearShort - 3],
|
||||
['{MM}', $monthShort + 1], // cast to int removes leading zero
|
||||
['{MM+1}', $monthShort + 1], // cast to int removes leading zero
|
||||
['{MM+2}', $monthShort + 2], // cast to int removes leading zero
|
||||
['{MM+3}', $monthShort + 3], // cast to int removes leading zero
|
||||
['{DD}', $dayShort + 1], // cast to int removes leading zero
|
||||
['{DD+1}', $dayShort + 1], // cast to int removes leading zero
|
||||
['{DD+2}', $dayShort + 2], // cast to int removes leading zero
|
||||
['{DD+3}', $dayShort + 3], // cast to int removes leading zero
|
||||
['{YY}', $yearLong(1)],
|
||||
['{YY+1}', $yearLong(1)],
|
||||
['{YY+2}', $yearLong(2)],
|
||||
['{YY+3}', $yearLong(3)],
|
||||
['{YY-1}', $yearLong(-1)],
|
||||
['{YY-2}', $yearLong(-2)],
|
||||
['{YY-3}', $yearLong(-3)],
|
||||
['{yy}', $yearShort(1)],
|
||||
['{yy+1}', $yearShort(1)],
|
||||
['{yy+2}', $yearShort(2)],
|
||||
['{yy+3}', $yearShort(3)],
|
||||
['{yy-1}', $yearShort(-1)],
|
||||
['{yy-2}', $yearShort(-2)],
|
||||
['{yy-3}', $yearShort(-3)],
|
||||
['{MM}', $monthShort(1)], // cast to int removes leading zero
|
||||
['{MM+1}', $monthShort(1)], // cast to int removes leading zero
|
||||
['{MM+2}', $monthShort(2)], // cast to int removes leading zero
|
||||
['{MM+3}', $monthShort(3)], // cast to int removes leading zero
|
||||
['{DD}', $dayShort(1)], // cast to int removes leading zero
|
||||
['{DD+1}', $dayShort(1)], // cast to int removes leading zero
|
||||
['{DD+2}', $dayShort(2)], // cast to int removes leading zero
|
||||
['{DD+3}', $dayShort(3)], // cast to int removes leading zero
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Tests\DependencyInjection;
|
||||
|
||||
use App\DependencyInjection\Configuration;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
|
||||
|
||||
@@ -125,6 +126,120 @@ class ConfigurationTest extends TestCase
|
||||
$this->assertConfig($config, []);
|
||||
}
|
||||
|
||||
public static function provideValidThemeConfigurations(): iterable
|
||||
{
|
||||
yield 'authentication auto theme' => [
|
||||
[
|
||||
'user' => [
|
||||
'theme' => 'auto',
|
||||
],
|
||||
],
|
||||
['user', 'theme'],
|
||||
'auto',
|
||||
];
|
||||
|
||||
yield 'authentication default theme' => [
|
||||
[
|
||||
'user' => [
|
||||
'theme' => 'default',
|
||||
],
|
||||
],
|
||||
['user', 'theme'],
|
||||
'default',
|
||||
];
|
||||
|
||||
yield 'authentication dark theme' => [
|
||||
[
|
||||
'user' => [
|
||||
'theme' => 'dark',
|
||||
],
|
||||
],
|
||||
['user', 'theme'],
|
||||
'dark',
|
||||
];
|
||||
|
||||
yield 'user default auto theme' => [
|
||||
[
|
||||
'defaults' => [
|
||||
'user' => [
|
||||
'theme' => 'auto',
|
||||
],
|
||||
],
|
||||
],
|
||||
['defaults', 'user', 'theme'],
|
||||
'auto',
|
||||
];
|
||||
|
||||
yield 'user default default theme' => [
|
||||
[
|
||||
'defaults' => [
|
||||
'user' => [
|
||||
'theme' => 'default',
|
||||
],
|
||||
],
|
||||
],
|
||||
['defaults', 'user', 'theme'],
|
||||
'default',
|
||||
];
|
||||
|
||||
yield 'user default dark theme' => [
|
||||
[
|
||||
'defaults' => [
|
||||
'user' => [
|
||||
'theme' => 'dark',
|
||||
],
|
||||
],
|
||||
],
|
||||
['defaults', 'user', 'theme'],
|
||||
'dark',
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('provideValidThemeConfigurations')]
|
||||
public function testValidateThemeAllowsSupportedValues(array $input, array $path, string $expected): void
|
||||
{
|
||||
$config = array_replace_recursive($this->getMinConfig(), $input);
|
||||
$compiled = $this->getCompiledConfig($config);
|
||||
|
||||
self::assertSame($expected, array_reduce($path, static function ($value, $key) {
|
||||
return $value[$key];
|
||||
}, $compiled));
|
||||
}
|
||||
|
||||
public static function provideInvalidThemeConfigurations(): iterable
|
||||
{
|
||||
yield 'authentication invalid theme' => [
|
||||
[
|
||||
'user' => [
|
||||
'theme' => 'blue',
|
||||
],
|
||||
],
|
||||
'kimai.user.theme',
|
||||
];
|
||||
|
||||
yield 'user default invalid theme' => [
|
||||
[
|
||||
'defaults' => [
|
||||
'user' => [
|
||||
'theme' => 'blue',
|
||||
],
|
||||
],
|
||||
],
|
||||
'kimai.defaults.user.theme',
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('provideInvalidThemeConfigurations')]
|
||||
public function testValidateThemeRejectsUnsupportedValues(array $input, string $path): void
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
$this->expectExceptionMessage(\sprintf('Invalid configuration for path "%s": The theme must be one of: "auto", "default", "dark"', $path));
|
||||
|
||||
$config = array_replace_recursive($this->getMinConfig(), $input);
|
||||
|
||||
$this->assertConfig($config, []);
|
||||
}
|
||||
|
||||
public function testValidateLdapFilterInvalidParenthesisCounter(): void
|
||||
{
|
||||
$this->expectException(InvalidConfigurationException::class);
|
||||
@@ -300,6 +415,7 @@ class ConfigurationTest extends TestCase
|
||||
'login' => true,
|
||||
'password_reset_retry_ttl' => 3600,
|
||||
'password_reset_token_ttl' => 86400,
|
||||
'theme' => 'auto',
|
||||
],
|
||||
'invoice' => [
|
||||
'documents' => [
|
||||
|
||||
@@ -481,6 +481,74 @@ class UserTest extends TestCase
|
||||
self::assertFalse($sut->initCanSeeAllData(true));
|
||||
}
|
||||
|
||||
public function testIsRegularUserOnly(): void
|
||||
{
|
||||
$sut = new User();
|
||||
self::assertTrue($sut->isRegularUserOnly());
|
||||
|
||||
$sut->setRoles([User::ROLE_USER]);
|
||||
self::assertTrue($sut->isRegularUserOnly());
|
||||
|
||||
$sut->addRole(User::ROLE_TEAMLEAD);
|
||||
self::assertFalse($sut->isRegularUserOnly());
|
||||
|
||||
$sut->removeRole(User::ROLE_TEAMLEAD);
|
||||
self::assertTrue($sut->isRegularUserOnly());
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
#[Group('legacy')]
|
||||
public function testCanSeeUserGrantsAccessForTeamleadToRegularUserWithoutTeam(): void
|
||||
{
|
||||
$requester = new User();
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
|
||||
self::assertTrue($subject->isRegularUserOnly());
|
||||
self::assertSame([], $subject->getTeams());
|
||||
self::assertTrue($requester->canSeeUser($subject));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
#[Group('legacy')]
|
||||
public function testCanSeeUserDeniesTeamleadFallbackForRegularUserWithTeam(): void
|
||||
{
|
||||
$requester = new User();
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
(new Team('Support'))->addUser($subject);
|
||||
|
||||
self::assertTrue($subject->isRegularUserOnly());
|
||||
self::assertNotSame([], $subject->getTeams());
|
||||
self::assertFalse($requester->canSeeUser($subject));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
#[Group('legacy')]
|
||||
public function testCanSeeUserDeniesTeamleadFallbackForNonRegularUserWithoutTeam(): void
|
||||
{
|
||||
$requester = new User();
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
$subject->addRole(User::ROLE_ADMIN);
|
||||
|
||||
self::assertFalse($subject->isRegularUserOnly());
|
||||
self::assertSame([], $subject->getTeams());
|
||||
self::assertFalse($requester->canSeeUser($subject));
|
||||
}
|
||||
|
||||
public function testSystemAccount(): void
|
||||
{
|
||||
$sut = new User();
|
||||
@@ -703,11 +771,20 @@ class UserTest extends TestCase
|
||||
self::assertTrue($user->isPasswordRequestNonExpired(3600));
|
||||
self::assertTrue($user->isPasswordRequestNonExpired(7200));
|
||||
|
||||
$before = date_default_timezone_get();
|
||||
date_default_timezone_set('America/Los_Angeles');
|
||||
date_default_timezone_set($before);
|
||||
$user->setTimezone('America/Los_Angeles');
|
||||
|
||||
self::assertTrue($user->isPasswordRequestNonExpired(3600));
|
||||
self::assertTrue($user->isPasswordRequestNonExpired(7200));
|
||||
}
|
||||
|
||||
private static function userWithId(int $id): User
|
||||
{
|
||||
$user = new User();
|
||||
$reflection = new \ReflectionClass($user);
|
||||
$property = $reflection->getProperty('id');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($user, $id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
127
tests/EventSubscriber/ThemeOptionsSubscriberTest.php
Normal file
127
tests/EventSubscriber/ThemeOptionsSubscriberTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\EventSubscriber\ThemeOptionsSubscriber;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use KevinPapst\TablerBundle\Helper\ContextHelper;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\KernelEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[CoversClass(ThemeOptionsSubscriber::class)]
|
||||
class ThemeOptionsSubscriberTest extends TestCase
|
||||
{
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
self::assertEquals([KernelEvents::CONTROLLER => ['setThemeOptions', 100]], ThemeOptionsSubscriber::getSubscribedEvents());
|
||||
}
|
||||
|
||||
public function testUsesAuthenticationThemeWithoutAuthenticatedUser(): void
|
||||
{
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$helper = new ContextHelper();
|
||||
$sut = $this->createSut($storage, $helper, ['user' => ['theme' => 'dark']]);
|
||||
|
||||
$sut->setThemeOptions($this->createMainRequestEvent('ar'));
|
||||
|
||||
self::assertTrue($helper->isRightToLeft());
|
||||
self::assertTrue($helper->isDarkMode());
|
||||
self::assertFalse($helper->isThemeAuto());
|
||||
self::assertTrue($helper->isHeaderDark());
|
||||
self::assertTrue($helper->isNavbarDark());
|
||||
self::assertFalse($helper->isBoxedLayout());
|
||||
self::assertFalse($helper->isCondensedUserMenu());
|
||||
self::assertFalse($helper->isCondensedNavbar());
|
||||
self::assertFalse($helper->isNavbarOverlapping());
|
||||
}
|
||||
|
||||
public function testUsesAuthenticationThemeForNonKimaiUserToken(): void
|
||||
{
|
||||
$securityUser = $this->createMock(UserInterface::class);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$helper = new ContextHelper();
|
||||
$sut = $this->createSut($storage, $helper, ['user' => ['theme' => 'auto']]);
|
||||
|
||||
$sut->setThemeOptions($this->createMainRequestEvent());
|
||||
|
||||
self::assertFalse($helper->isDarkMode());
|
||||
self::assertTrue($helper->isThemeAuto());
|
||||
self::assertFalse($helper->isHeaderDark());
|
||||
}
|
||||
|
||||
public function testUserThemeOverridesAuthenticationTheme(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setPreferenceValue(UserPreference::SKIN, 'dark');
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->expects($this->once())->method('getUser')->willReturn($user);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$helper = new ContextHelper();
|
||||
$sut = $this->createSut($storage, $helper, ['user' => ['theme' => 'auto']]);
|
||||
|
||||
$sut->setThemeOptions($this->createMainRequestEvent());
|
||||
|
||||
self::assertFalse($helper->isRightToLeft());
|
||||
self::assertTrue($helper->isDarkMode());
|
||||
self::assertFalse($helper->isThemeAuto());
|
||||
self::assertTrue($helper->isHeaderDark());
|
||||
}
|
||||
|
||||
private function createSut(TokenStorageInterface $storage, ContextHelper $helper, array $settings = []): ThemeOptionsSubscriber
|
||||
{
|
||||
return new ThemeOptionsSubscriber(
|
||||
$storage,
|
||||
$helper,
|
||||
new LocaleService([
|
||||
'en' => LocaleService::DEFAULT_SETTINGS,
|
||||
'ar' => [
|
||||
'date' => 'dd.MM.y',
|
||||
'time' => 'H:mm',
|
||||
'rtl' => true,
|
||||
'translation' => false,
|
||||
],
|
||||
]),
|
||||
SystemConfigurationFactory::createStub($settings)
|
||||
);
|
||||
}
|
||||
|
||||
private function createMainRequestEvent(string $locale = 'en'): KernelEvent
|
||||
{
|
||||
$request = new Request();
|
||||
$request->setLocale($locale);
|
||||
|
||||
$event = $this->createMock(KernelEvent::class);
|
||||
$event->expects($this->once())->method('isMainRequest')->willReturn(true);
|
||||
$event->expects($this->once())->method('getRequest')->willReturn($request);
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,22 @@ class InvoiceModelDefaultHydratorTest extends TestCase
|
||||
|
||||
$result = $sut->hydrate($model);
|
||||
$this->assertModelStructure($result);
|
||||
self::assertSame(
|
||||
$model->getFormatter()->getFormattedDateTime($model->getInvoicePeriod()->getStart()),
|
||||
$result['invoice.first']
|
||||
);
|
||||
self::assertSame(
|
||||
$model->getInvoicePeriod()->getStart()->format('Y-m-d h:i:s'),
|
||||
$result['invoice.first_process']
|
||||
);
|
||||
self::assertSame(
|
||||
$model->getFormatter()->getFormattedDateTime($model->getInvoicePeriod()->getEnd()),
|
||||
$result['invoice.last']
|
||||
);
|
||||
self::assertSame(
|
||||
$model->getInvoicePeriod()->getEnd()->format('Y-m-d h:i:s'),
|
||||
$result['invoice.last_process']
|
||||
);
|
||||
}
|
||||
|
||||
public function testHydrateThrowsOnMissing(): void
|
||||
@@ -67,8 +83,12 @@ class InvoiceModelDefaultHydratorTest extends TestCase
|
||||
'invoice.total_time',
|
||||
'invoice.duration_decimal',
|
||||
'invoice.first',
|
||||
'invoice.first_month',
|
||||
'invoice.first_year',
|
||||
'invoice.first_process',
|
||||
'invoice.last',
|
||||
'invoice.last_month',
|
||||
'invoice.last_year',
|
||||
'invoice.last_process',
|
||||
'invoice.total',
|
||||
'invoice.total_nc',
|
||||
|
||||
@@ -33,6 +33,7 @@ class InvoiceModelProjectHydratorTest extends TestCase
|
||||
public function assertModelStructure(array $model): void
|
||||
{
|
||||
$keys = [
|
||||
'project._counter',
|
||||
'project.id',
|
||||
'project.name',
|
||||
'project.comment',
|
||||
|
||||
@@ -13,15 +13,21 @@ use App\Entity\Customer;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Invoice\Calculator\DefaultCalculator;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\InvoicePeriod;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Tests\Invoice\NumberGenerator\IncrementingNumberGenerator;
|
||||
use App\Tests\Invoice\Renderer\RendererTestTrait;
|
||||
use App\Tests\Mocks\InvoiceModelFactoryFactory;
|
||||
use App\Timesheet\RateCalculator\DecimalRateCalculator;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(InvoiceModel::class)]
|
||||
#[CoversClass(InvoicePeriod::class)]
|
||||
class InvoiceModelTest extends TestCase
|
||||
{
|
||||
use RendererTestTrait;
|
||||
|
||||
public function testEmptyObject(): void
|
||||
{
|
||||
$formatter = new DebugFormatter();
|
||||
@@ -119,4 +125,50 @@ class InvoiceModelTest extends TestCase
|
||||
$expected = new \DateTimeImmutable('2022-06-06');
|
||||
self::assertEquals($expected->format('Y-m-d'), $dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testGetInvoicePeriod(): void
|
||||
{
|
||||
$sut = $this->getInvoiceModel();
|
||||
|
||||
$period = $sut->getInvoicePeriod();
|
||||
|
||||
self::assertSame('2020-08-12 18:00:00', $period->getStart()->format('Y-m-d H:i:s'));
|
||||
self::assertSame('2021-03-12 12:17:40', $period->getEnd()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testGetInvoicePeriodFallsBackToQueryDates(): void
|
||||
{
|
||||
$query = new InvoiceQuery();
|
||||
$query->setBegin(new \DateTime('2022-01-02 03:04:05'));
|
||||
$query->setEnd(new \DateTime('2022-06-07 08:09:10'));
|
||||
|
||||
$sut = (new InvoiceModelFactoryFactory($this))->create()->createModel(
|
||||
new DebugFormatter(),
|
||||
new Customer('foo'),
|
||||
new InvoiceTemplate(),
|
||||
$query
|
||||
);
|
||||
|
||||
$period = $sut->getInvoicePeriod();
|
||||
|
||||
self::assertSame('2022-01-02 00:00:00', $period->getStart()->format('Y-m-d H:i:s'));
|
||||
self::assertSame('2022-06-07 23:59:59', $period->getEnd()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testGetInvoicePeriodFallsBackToInvoiceDateWithoutQuery(): void
|
||||
{
|
||||
$invoiceDate = new \DateTimeImmutable('2023-09-10 11:12:13');
|
||||
$sut = new InvoiceModel(
|
||||
new DebugFormatter(),
|
||||
new Customer('foo'),
|
||||
new InvoiceTemplate(),
|
||||
new DecimalRateCalculator()
|
||||
);
|
||||
$sut->setInvoiceDate($invoiceDate);
|
||||
|
||||
$period = $sut->getInvoicePeriod();
|
||||
|
||||
self::assertSame('2023-09-10 11:12:13', $period->getStart()->format('Y-m-d H:i:s'));
|
||||
self::assertSame('2023-09-10 11:12:13', $period->getEnd()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
}
|
||||
|
||||
29
tests/Invoice/InvoicePeriodTest.php
Normal file
29
tests/Invoice/InvoicePeriodTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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\Invoice;
|
||||
|
||||
use App\Invoice\InvoicePeriod;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(InvoicePeriod::class)]
|
||||
class InvoicePeriodTest extends TestCase
|
||||
{
|
||||
public function testGetters(): void
|
||||
{
|
||||
$start = new \DateTimeImmutable('2024-01-02 03:04:05');
|
||||
$end = new \DateTimeImmutable('2024-06-07 08:09:10');
|
||||
|
||||
$sut = new InvoicePeriod($start, $end);
|
||||
|
||||
self::assertSame($start, $sut->getStart());
|
||||
self::assertSame($end, $sut->getEnd());
|
||||
}
|
||||
}
|
||||
@@ -104,8 +104,12 @@ class DebugRendererTest extends TestCase
|
||||
'invoice.total_time',
|
||||
'invoice.duration_decimal',
|
||||
'invoice.first',
|
||||
'invoice.first_month',
|
||||
'invoice.first_year',
|
||||
'invoice.first_process',
|
||||
'invoice.last',
|
||||
'invoice.last_month',
|
||||
'invoice.last_year',
|
||||
'invoice.last_process',
|
||||
'invoice.total',
|
||||
'invoice.total_nc',
|
||||
@@ -210,6 +214,7 @@ class DebugRendererTest extends TestCase
|
||||
'user.meta.hello',
|
||||
'user.meta.kitty',
|
||||
'testFromModelHydrator',
|
||||
'project._counter',
|
||||
];
|
||||
|
||||
if ($activityCounter === 1) {
|
||||
|
||||
@@ -172,8 +172,11 @@ class ProjectServiceTest extends TestCase
|
||||
self::assertNull($project->getCustomer());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure(\DateTimeInterface): string $expected
|
||||
*/
|
||||
#[DataProvider('getTestData')]
|
||||
public function testProjectNumber(string $format, int|string $expected): void
|
||||
public function testProjectNumber(string $format, \Closure $expected): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'project' => [
|
||||
@@ -183,64 +186,64 @@ class ProjectServiceTest extends TestCase
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
|
||||
$date = new \DateTimeImmutable();
|
||||
$project = $sut->createNewProject();
|
||||
|
||||
self::assertEquals((string) $expected, $project->getNumber());
|
||||
self::assertEquals($expected($date), $project->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, string|\DateTime|int>>
|
||||
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
|
||||
*/
|
||||
public static function getTestData(): array
|
||||
{
|
||||
$dateTime = new \DateTime();
|
||||
|
||||
$yearLong = (int) $dateTime->format('Y');
|
||||
$yearShort = (int) $dateTime->format('y');
|
||||
$monthLong = $dateTime->format('m');
|
||||
$monthShort = (int) $dateTime->format('n');
|
||||
$dayLong = $dateTime->format('d');
|
||||
$dayShort = (int) $dateTime->format('j');
|
||||
$literal = static fn (string $value): \Closure => static fn (): string => $value;
|
||||
$date = static fn (string $format): \Closure => static fn (\DateTimeInterface $d): string => $d->format($format);
|
||||
$yearLong = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('Y') + $add);
|
||||
$yearShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('y') + $add);
|
||||
$monthShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('m') + $add);
|
||||
$dayShort = static fn (int $add): \Closure => static fn (\DateTimeInterface $d): string => (string) ((int) $d->format('d') + $add);
|
||||
|
||||
return [
|
||||
// simple tests for single calls
|
||||
['{pc,1}', '2'],
|
||||
['{pc,2}', '02'],
|
||||
['{pc,3}', '002'],
|
||||
['{pc,4}', '0002'],
|
||||
['{Y}', $yearLong],
|
||||
['{y}', $yearShort],
|
||||
['{M}', $monthLong],
|
||||
['{m}', $monthShort],
|
||||
['{D}', $dayLong],
|
||||
['{d}', $dayShort],
|
||||
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
|
||||
['{Y,6}', '00' . $yearLong],
|
||||
['{M,3}', '0' . $monthLong],
|
||||
['{D,3}', '0' . $dayLong],
|
||||
['{pc,1}', $literal('2')],
|
||||
['{pc,2}', $literal('02')],
|
||||
['{pc,3}', $literal('002')],
|
||||
['{pc,4}', $literal('0002')],
|
||||
['{Y}', $date('Y')],
|
||||
['{y}', $date('y')],
|
||||
['{M}', $date('m')],
|
||||
['{m}', $date('n')],
|
||||
['{D}', $date('d')],
|
||||
['{d}', $date('j')],
|
||||
// number formatting
|
||||
['{Y,6}', static fn (\DateTimeInterface $d): string => '00' . $d->format('Y')],
|
||||
['{M,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('m')],
|
||||
['{D,3}', static fn (\DateTimeInterface $d): string => '0' . $d->format('d')],
|
||||
// increment dates
|
||||
['{YY}', $yearLong + 1],
|
||||
['{YY+1}', $yearLong + 1],
|
||||
['{YY+2}', $yearLong + 2],
|
||||
['{YY+3}', $yearLong + 3],
|
||||
['{YY-1}', $yearLong - 1],
|
||||
['{YY-2}', $yearLong - 2],
|
||||
['{YY-3}', $yearLong - 3],
|
||||
['{yy}', $yearShort + 1],
|
||||
['{yy+1}', $yearShort + 1],
|
||||
['{yy+2}', $yearShort + 2],
|
||||
['{yy+3}', $yearShort + 3],
|
||||
['{yy-1}', $yearShort - 1],
|
||||
['{yy-2}', $yearShort - 2],
|
||||
['{yy-3}', $yearShort - 3],
|
||||
['{MM}', $monthShort + 1], // cast to int removes leading zero
|
||||
['{MM+1}', $monthShort + 1], // cast to int removes leading zero
|
||||
['{MM+2}', $monthShort + 2], // cast to int removes leading zero
|
||||
['{MM+3}', $monthShort + 3], // cast to int removes leading zero
|
||||
['{DD}', $dayShort + 1], // cast to int removes leading zero
|
||||
['{DD+1}', $dayShort + 1], // cast to int removes leading zero
|
||||
['{DD+2}', $dayShort + 2], // cast to int removes leading zero
|
||||
['{DD+3}', $dayShort + 3], // cast to int removes leading zero
|
||||
['{YY}', $yearLong(1)],
|
||||
['{YY+1}', $yearLong(1)],
|
||||
['{YY+2}', $yearLong(2)],
|
||||
['{YY+3}', $yearLong(3)],
|
||||
['{YY-1}', $yearLong(-1)],
|
||||
['{YY-2}', $yearLong(-2)],
|
||||
['{YY-3}', $yearLong(-3)],
|
||||
['{yy}', $yearShort(1)],
|
||||
['{yy+1}', $yearShort(1)],
|
||||
['{yy+2}', $yearShort(2)],
|
||||
['{yy+3}', $yearShort(3)],
|
||||
['{yy-1}', $yearShort(-1)],
|
||||
['{yy-2}', $yearShort(-2)],
|
||||
['{yy-3}', $yearShort(-3)],
|
||||
['{MM}', $monthShort(1)], // cast to int removes leading zero
|
||||
['{MM+1}', $monthShort(1)], // cast to int removes leading zero
|
||||
['{MM+2}', $monthShort(2)], // cast to int removes leading zero
|
||||
['{MM+3}', $monthShort(3)], // cast to int removes leading zero
|
||||
['{DD}', $dayShort(1)], // cast to int removes leading zero
|
||||
['{DD+1}', $dayShort(1)], // cast to int removes leading zero
|
||||
['{DD+2}', $dayShort(2)], // cast to int removes leading zero
|
||||
['{DD+3}', $dayShort(3)], // cast to int removes leading zero
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,6 +807,164 @@ class RolePermissionManagerTest extends TestCase
|
||||
self::assertFalse($sut->checkTeamAccessTimesheet($timesheet, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessGrantsAccessForSameUserId(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(42);
|
||||
$subject->setEnabled(false);
|
||||
$subject->setSystemAccount(true);
|
||||
|
||||
$requester = self::userWithId(42);
|
||||
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessGrantsAccessForSuperAdmin(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setSuperAdmin(true);
|
||||
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessGrantsAccessForCanSeeAllData(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->initCanSeeAllData(true);
|
||||
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessDeniesDisabledSubject(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(false);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessDeniesSystemAccountForNonSystemRequester(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
$subject->setSystemAccount(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessGrantsTeamleadOfUser(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$team = new Team('Support');
|
||||
$team->addUser($subject);
|
||||
$team->addTeamlead($requester);
|
||||
|
||||
self::assertTrue($requester->isTeamleadOfUser($subject));
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessGrantsAdminFallbackForRegularUserWithoutTeam(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_ADMIN]);
|
||||
|
||||
self::assertTrue($subject->isRegularUserOnly());
|
||||
self::assertSame([], $subject->getTeams());
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessGrantsTeamleadFallbackForRegularUserWithoutTeam(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_TEAMLEAD]);
|
||||
|
||||
self::assertTrue($subject->isRegularUserOnly());
|
||||
self::assertSame([], $subject->getTeams());
|
||||
self::assertTrue($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessDeniesFallbackForRegularUserWithTeam(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
(new Team('Support'))->addUser($subject);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_ADMIN]);
|
||||
|
||||
self::assertTrue($subject->isRegularUserOnly());
|
||||
self::assertNotSame([], $subject->getTeams());
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessDeniesFallbackForNonRegularUserWithoutTeam(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
$subject->addRole(User::ROLE_TEAMLEAD);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setRoles([User::ROLE_ADMIN]);
|
||||
|
||||
self::assertFalse($subject->isRegularUserOnly());
|
||||
self::assertSame([], $subject->getTeams());
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
public function testCheckUserAccessDeniesSystemRequesterWithoutMatchingAccessPath(): void
|
||||
{
|
||||
$sut = $this->createSut();
|
||||
|
||||
$subject = self::userWithId(1);
|
||||
$subject->setEnabled(true);
|
||||
$subject->setSystemAccount(true);
|
||||
|
||||
$requester = self::userWithId(2);
|
||||
$requester->setSystemAccount(true);
|
||||
|
||||
self::assertFalse($sut->checkUserAccess($subject, $requester));
|
||||
}
|
||||
|
||||
private static function userWithId(int $id): User
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
@@ -43,7 +43,9 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->oldTimezone !== null) {
|
||||
date_default_timezone_set($this->oldTimezone);
|
||||
}
|
||||
$this->oldTimezone = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,16 @@ use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DependencyInjection\Container;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
|
||||
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
|
||||
use Twig\Error\RuntimeError;
|
||||
|
||||
#[CoversClass(EncoreExtension::class)]
|
||||
class EncoreExtensionTest extends TestCase
|
||||
{
|
||||
protected function getSut(array $files = []): EncoreExtension
|
||||
protected function getSut(array $files = [], bool $expectsReset = true): EncoreExtension
|
||||
{
|
||||
$entryLookup = $this->createMock(EntrypointLookupInterface::class);
|
||||
$entryLookup->expects($this->any())->method('getCssFiles')->willReturn($files);
|
||||
$entryLookup->expects($expectsReset ? $this->once() : $this->never())->method('reset');
|
||||
|
||||
$container = new Container(new ParameterBag([]));
|
||||
$container->set(EntrypointLookupInterface::class, $entryLookup);
|
||||
@@ -41,6 +43,32 @@ class EncoreExtensionTest extends TestCase
|
||||
$css = 'body { margin: 0; }p
|
||||
{
|
||||
color: red; font-style: italic; }';
|
||||
self::assertEquals($css, $sut->getEncoreEntryCssSource('blub'));
|
||||
self::assertEquals($css, $sut->getEncoreEntryCssSource('invoice'));
|
||||
}
|
||||
|
||||
public function testGetEncoreEntryCssSourceIgnoresNonCssFiles(): void
|
||||
{
|
||||
$sut = $this->getSut(['test.css', 'test.js', 'test1.css', 'build/app.css.map']);
|
||||
$css = 'body { margin: 0; }p
|
||||
{
|
||||
color: red; font-style: italic; }';
|
||||
|
||||
self::assertEquals($css, $sut->getEncoreEntryCssSource('invoice-pdf'));
|
||||
}
|
||||
|
||||
public function testGetEncoreEntryCssSourceIgnoresDirectoryTraversalPaths(): void
|
||||
{
|
||||
$sut = $this->getSut(['../composer.json', 'test.css', 'foo/../test1.css', '../ContextTest.php']);
|
||||
|
||||
self::assertSame('body { margin: 0; }', $sut->getEncoreEntryCssSource('export-pdf'));
|
||||
}
|
||||
|
||||
public function testGetEncoreEntryCssSourceRejectsUnknownPackage(): void
|
||||
{
|
||||
$this->expectException(RuntimeError::class);
|
||||
$this->expectExceptionMessage('Unknown CSS package requested: blub');
|
||||
|
||||
$sut = $this->getSut([], false);
|
||||
$sut->getEncoreEntryCssSource('blub');
|
||||
}
|
||||
}
|
||||
|
||||
2
tests/Twig/public/test.js
Normal file
2
tests/Twig/public/test.js
Normal file
@@ -0,0 +1,2 @@
|
||||
function foo(message) { alert(message); };
|
||||
foo('bar');
|
||||
120
tests/Utils/LocaleFormatterTest.php
Normal file
120
tests/Utils/LocaleFormatterTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?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\Utils;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Utils\LocaleFormatter;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(LocaleFormatter::class)]
|
||||
class LocaleFormatterTest extends TestCase
|
||||
{
|
||||
private ?string $oldTimezone = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->oldTimezone = date_default_timezone_get();
|
||||
date_default_timezone_set('Europe/Vienna');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->oldTimezone !== null) {
|
||||
date_default_timezone_set($this->oldTimezone);
|
||||
}
|
||||
|
||||
$this->oldTimezone = null;
|
||||
}
|
||||
|
||||
public function testDurationFormattingWithTimesheet(): void
|
||||
{
|
||||
$sut = $this->getSut('en');
|
||||
|
||||
$timesheet = (new Timesheet())
|
||||
->setBegin(new \DateTime('2020-07-09 08:00:00', new \DateTimeZone('Europe/Vienna')))
|
||||
->setEnd(new \DateTime('2020-07-09 10:37:17', new \DateTimeZone('Europe/Vienna')))
|
||||
->setDuration(9437);
|
||||
|
||||
self::assertSame('2:37', $sut->duration($timesheet));
|
||||
self::assertSame('2.62', $sut->duration($timesheet, true));
|
||||
self::assertSame('2.62', $sut->durationDecimal($timesheet));
|
||||
}
|
||||
|
||||
public function testDurationDecimalWorksAfterAmountFormatting(): void
|
||||
{
|
||||
$sut = $this->getSut('de');
|
||||
|
||||
self::assertSame('1.234,5', $sut->amount(1234.5));
|
||||
self::assertSame('2,62', $sut->durationDecimal(9437));
|
||||
}
|
||||
|
||||
public function testAmountAndMoneyFormatting(): void
|
||||
{
|
||||
$sut = $this->getSut('en');
|
||||
|
||||
self::assertSame('0', $sut->amount(null));
|
||||
self::assertSame('2,345.009', $sut->amount(2345.009));
|
||||
self::assertSame('€2,345.01', $sut->money(2345.009, 'EUR'));
|
||||
self::assertSame('2,345.01', $sut->money(2345.009, 'EUR', false));
|
||||
}
|
||||
|
||||
public function testCurrencyFormattingFallsBackToInput(): void
|
||||
{
|
||||
$sut = $this->getSut('de');
|
||||
|
||||
self::assertSame('', $sut->currency(null));
|
||||
self::assertSame('€', $sut->currency('eur'));
|
||||
self::assertSame('INVALID', $sut->currency('INVALID'));
|
||||
}
|
||||
|
||||
public function testDateAndTimeFormatting(): void
|
||||
{
|
||||
$sut = $this->getSut('de');
|
||||
$date = new \DateTimeImmutable('1980-12-14 13:27:55', new \DateTimeZone('Europe/Vienna'));
|
||||
|
||||
self::assertSame('14.12.1980', $sut->dateShort($date));
|
||||
self::assertSame('14.12.1980 13:27:55', $sut->dateTime($date));
|
||||
self::assertSame('1980-12-14T13:27:55+01:00', $sut->dateFormat($date, 'c'));
|
||||
self::assertSame('13:27:55', $sut->time($date));
|
||||
}
|
||||
|
||||
public function testInvalidDateInputReturnsNull(): void
|
||||
{
|
||||
$sut = $this->getSut('en');
|
||||
|
||||
self::assertNull($sut->dateShort('not-a-date'));
|
||||
self::assertNull($sut->dateTime('not-a-date'));
|
||||
self::assertNull($sut->dateFormat('not-a-date', 'c'));
|
||||
self::assertNull($sut->time('not-a-date'));
|
||||
}
|
||||
|
||||
public function testLocalizedNames(): void
|
||||
{
|
||||
$sut = $this->getSut('en');
|
||||
$date = new \DateTimeImmutable('2020-07-09 12:00:00', new \DateTimeZone('Europe/Vienna'));
|
||||
|
||||
self::assertSame('July', $sut->monthName($date));
|
||||
self::assertSame('July 2020', $sut->monthName($date, true));
|
||||
self::assertSame('Q3', $sut->quarterName($date));
|
||||
self::assertSame('Q3 2020', $sut->quarterName($date, true));
|
||||
self::assertSame('Thursday', $sut->dayName($date));
|
||||
self::assertSame('Thu', $sut->dayName($date, true));
|
||||
}
|
||||
|
||||
private function getSut(string $locale): LocaleFormatter
|
||||
{
|
||||
return new LocaleFormatter(new LocaleService([
|
||||
'de' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'dd.MM.Y', 'time' => 'HH:mm:ss']),
|
||||
'en' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'Y-MM-dd', 'time' => 'HH:mm']),
|
||||
]), $locale);
|
||||
}
|
||||
}
|
||||
242
tests/Validator/Constraints/TimesheetTeamAccessValidatorTest.php
Normal file
242
tests/Validator/Constraints/TimesheetTeamAccessValidatorTest.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Security\RolePermissionManager;
|
||||
use App\User\PermissionService;
|
||||
use App\Validator\Constraints\TimesheetTeamAccess;
|
||||
use App\Validator\Constraints\TimesheetTeamAccessValidator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @extends ConstraintValidatorTestCase<TimesheetTeamAccessValidator>
|
||||
*/
|
||||
#[CoversClass(TimesheetTeamAccess::class)]
|
||||
#[CoversClass(TimesheetTeamAccessValidator::class)]
|
||||
class TimesheetTeamAccessValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator(): TimesheetTeamAccessValidator
|
||||
{
|
||||
return $this->createMyValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $originalData
|
||||
*/
|
||||
protected function createMyValidator(
|
||||
array $originalData = [],
|
||||
?User $user = null
|
||||
): TimesheetTeamAccessValidator
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($user ?? new User());
|
||||
|
||||
$permissionService = $this->createMock(PermissionService::class);
|
||||
$permissionService->method('getPermissions')->willReturn([]);
|
||||
$permissionManager = new RolePermissionManager($permissionService, [], []);
|
||||
|
||||
$unitOfWork = $this->createMock(UnitOfWork::class);
|
||||
$unitOfWork->method('getOriginalEntityData')->willReturn($originalData);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->method('getUnitOfWork')->willReturn($unitOfWork);
|
||||
|
||||
$registry = $this->createMock(ManagerRegistry::class);
|
||||
$registry->method('getManagerForClass')->willReturn($entityManager);
|
||||
|
||||
return new TimesheetTeamAccessValidator($security, $permissionManager, $registry);
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid(): void
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new Timesheet(), new NotBlank());
|
||||
}
|
||||
|
||||
public function testInvalidValueThrowsException(): void
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate(new NotBlank(), new TimesheetTeamAccess(['message' => 'myMessage']));
|
||||
}
|
||||
|
||||
public function testTriggersForNewTimesheetWithInaccessibleProject(): void
|
||||
{
|
||||
$this->validator = $this->createMyValidator();
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setProject($this->createRestrictedProject('restricted'));
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetTeamAccess());
|
||||
|
||||
$this->buildViolation('You are not allowed to use this project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDoesNotReadOriginalDataForNewTimesheet(): void
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(new User());
|
||||
|
||||
$permissionService = $this->createMock(PermissionService::class);
|
||||
$permissionService->method('getPermissions')->willReturn([]);
|
||||
$permissionManager = new RolePermissionManager($permissionService, [], []);
|
||||
|
||||
$registry = $this->createMock(ManagerRegistry::class);
|
||||
$registry->expects(self::never())->method('getManagerForClass');
|
||||
|
||||
$this->validator = new TimesheetTeamAccessValidator($security, $permissionManager, $registry);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setProject($this->createRestrictedProject('new-timesheet'));
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetTeamAccess());
|
||||
|
||||
$this->buildViolation('You are not allowed to use this project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDoesNotTriggerForExistingTimesheetWithUnchangedProject(): void
|
||||
{
|
||||
$originalProject = $this->createRestrictedProject('restricted');
|
||||
|
||||
$this->validator = $this->createMyValidator(['project' => $originalProject]);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = $this->createPersistedTimesheet();
|
||||
$timesheet->setProject($originalProject);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetTeamAccess());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testTriggersForExistingTimesheetWithChangedProject(): void
|
||||
{
|
||||
$this->validator = $this->createMyValidator(['project' => $this->createProject('old')]);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = $this->createPersistedTimesheet();
|
||||
$timesheet->setProject($this->createRestrictedProject('new'));
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetTeamAccess());
|
||||
|
||||
$this->buildViolation('You are not allowed to use this project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetTeamAccess::PROJECT_ACCESS_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testTriggersForExistingTimesheetWithChangedActivity(): void
|
||||
{
|
||||
$this->validator = $this->createMyValidator(['activity' => $this->createActivity('old')]);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = $this->createPersistedTimesheet();
|
||||
$timesheet->setActivity($this->createRestrictedActivity('new'));
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetTeamAccess());
|
||||
|
||||
$this->buildViolation('You are not allowed to use this activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetTeamAccess::ACTIVITY_ACCESS_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDoesNotTriggerForSuperAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setRoles([User::ROLE_SUPER_ADMIN]);
|
||||
|
||||
$this->validator = $this->createMyValidator([], $user);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setProject($this->createRestrictedProject('restricted'))
|
||||
->setActivity($this->createRestrictedActivity('restricted'))
|
||||
;
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetTeamAccess());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testGetTargets(): void
|
||||
{
|
||||
$constraint = new TimesheetTeamAccess();
|
||||
self::assertEquals('class', $constraint->getTargets());
|
||||
}
|
||||
|
||||
private function createPersistedTimesheet(): Timesheet
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$reflection = new \ReflectionClass($timesheet);
|
||||
$property = $reflection->getProperty('id');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($timesheet, 1);
|
||||
$property->setAccessible(false);
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
private function createProject(string $name): Project
|
||||
{
|
||||
$project = new Project();
|
||||
$project->setName($name);
|
||||
$project->setCustomer(new Customer('customer-' . $name));
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
private function createActivity(string $name): Activity
|
||||
{
|
||||
$activity = new Activity();
|
||||
$activity->setName($name);
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function createRestrictedProject(string $name): Project
|
||||
{
|
||||
$project = $this->createProject($name);
|
||||
$project->getCustomer()?->addTeam(new Team('customer-team-' . $name));
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
private function createRestrictedActivity(string $name): Activity
|
||||
{
|
||||
$activity = $this->createActivity($name);
|
||||
$activity->addTeam(new Team('activity-team-' . $name));
|
||||
|
||||
return $activity;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\MultiUserTimesheet;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use App\Timesheet\LockdownService;
|
||||
use App\Voter\TimesheetVoter;
|
||||
@@ -169,6 +170,62 @@ class TimesheetVoterTest extends AbstractVoterTestCase
|
||||
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
public function testIsOwnerGrantedForOwnTimesheet(): void
|
||||
{
|
||||
// is_owner bypasses the RolePermissionManager entirely: a user with no
|
||||
// role/permissions at all must still be recognised as the owner.
|
||||
$owner = self::getUser(1, 'unknown');
|
||||
|
||||
$timesheet = self::getTimesheet($owner);
|
||||
|
||||
$this->assertVote($owner, $timesheet, 'is_owner', VoterInterface::ACCESS_GRANTED);
|
||||
}
|
||||
|
||||
public function testIsOwnerDeniedForOtherUsersTimesheet(): void
|
||||
{
|
||||
$owner = self::getUser(1, User::ROLE_USER);
|
||||
$other = self::getUser(2, User::ROLE_SUPER_ADMIN);
|
||||
|
||||
$timesheet = self::getTimesheet($owner);
|
||||
|
||||
// even a super admin is not the *owner* of someone else's timesheet
|
||||
$this->assertVote($other, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
public function testIsOwnerUsesObjectIdentityNotId(): void
|
||||
{
|
||||
// The is_owner branch compares with strict identity ($user === $subject->getUser()),
|
||||
// not by id like the permission-based branches. Two distinct User instances that
|
||||
// share the same id are therefore NOT considered the same owner.
|
||||
$tokenUser = self::getUser(1, User::ROLE_USER);
|
||||
$timesheetUser = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
$timesheet = self::getTimesheet($timesheetUser);
|
||||
|
||||
$this->assertVote($tokenUser, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
public function testIsOwnerDeniedWhenTimesheetHasNoUser(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
|
||||
$this->assertVote($user, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
public function testIsOwnerDeniedForMultiUserTimesheetEvenWhenSameUser(): void
|
||||
{
|
||||
// A MultiUserTimesheet is explicitly excluded from being "owned",
|
||||
// regardless of the assigned user.
|
||||
$owner = self::getUser(1, User::ROLE_USER);
|
||||
|
||||
$timesheet = new MultiUserTimesheet();
|
||||
$timesheet->setUser($owner);
|
||||
|
||||
$this->assertVote($owner, $timesheet, 'is_owner', VoterInterface::ACCESS_DENIED);
|
||||
}
|
||||
|
||||
private static function getTimesheet($user): Timesheet
|
||||
{
|
||||
$activity = new Activity();
|
||||
|
||||
@@ -20,12 +20,12 @@ parameters:
|
||||
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php
|
||||
ignoreErrors:
|
||||
- identifier: missingType.iterableValue
|
||||
|
||||
-
|
||||
message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with '(.*)' and (.*) will always evaluate to true\\.$#"
|
||||
|
||||
-
|
||||
message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
|
||||
- message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with '(.*)' and (.*) will always evaluate to true\\.$#"
|
||||
- message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
|
||||
- message: '#^Call to deprecated method getApiToken\(\) of class App\\Entity\\User#'
|
||||
- message: '#^Call to deprecated method setApiToken\(\) of class App\\Entity\\User#'
|
||||
- message: '#^Call to deprecated method getPlainApiToken\(\) of class App\\Entity\\User#'
|
||||
- message: '#^Call to deprecated method setPlainApiToken\(\) of class App\\Entity\\User#'
|
||||
|
||||
-
|
||||
identifier: classConstant.deprecatedClass
|
||||
@@ -47,6 +47,11 @@ parameters:
|
||||
count: 13
|
||||
path: API/Authentication/TokenAuthenticatorTest.php
|
||||
|
||||
-
|
||||
identifier: method.deprecated
|
||||
count: 2
|
||||
path: API/Authentication/TokenAuthenticatorTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -581,16 +586,6 @@ parameters:
|
||||
count: 1
|
||||
path: Controller/CustomerControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 2
|
||||
path: Controller/CustomerControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$url of method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 2
|
||||
path: Controller/CustomerControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$project of method App\\\\Entity\\\\Team\\:\\:addProject\\(\\) expects App\\\\Entity\\\\Project, App\\\\Entity\\\\Project\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -781,16 +776,6 @@ parameters:
|
||||
count: 1
|
||||
path: Controller/ProjectControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 2
|
||||
path: Controller/ProjectControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$url of method App\\\\Tests\\\\Controller\\\\AbstractControllerBaseTestCase\\:\\:request\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 2
|
||||
path: Controller/ProjectControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$childNodes on DOMNode\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -2081,11 +2066,6 @@ parameters:
|
||||
count: 5
|
||||
path: Twig/LocaleFormatExtensionsTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$timezoneId of function date_default_timezone_set expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: Twig/LocaleFormatExtensionsTest.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:\\$localeDe type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user