mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-13 08:13:18 +01:00
Compare commits
1 Commits
dependabot
...
fix/time-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d34513bde |
7
.github/workflows/phpunit-extensions.yml
vendored
7
.github/workflows/phpunit-extensions.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
||||
jobs:
|
||||
phpunit-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
@@ -132,8 +132,5 @@ jobs:
|
||||
php artisan key:generate
|
||||
php artisan passport:keys
|
||||
|
||||
- name: "Run PHPUnit (extensions)"
|
||||
- name: "Run PHPUnit"
|
||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||
|
||||
- name: "Run PHPUnit (core)"
|
||||
run: php artisan test --testsuite=Unit,Feature --stop-on-failure
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner).
|
||||
# The scanner builds this image with the repository root as the build context and then audits it without network
|
||||
# access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend,
|
||||
# a local PostgreSQL and a local Gotenberg for PDF rendering) is installed here.
|
||||
#
|
||||
# The base image is the FrankenPHP image the production image (docker/prod/Dockerfile) is built on: in production the
|
||||
# app runs as long-lived Laravel Octane workers in FrankenPHP worker mode, not as one PHP process per request.
|
||||
#
|
||||
# Inside the finished image:
|
||||
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
|
||||
# .oss-scanner/start-gotenberg.sh start the local Gotenberg server (required for PDF exports)
|
||||
# php artisan test run the PHPUnit suite (needs PostgreSQL and Gotenberg running)
|
||||
# .oss-scanner/start-octane.sh run the app like production on http://127.0.0.1:8000 (starts everything above)
|
||||
|
||||
ARG FRANKENPHP_VERSION=1.11
|
||||
ARG PHP_VERSION=8.3
|
||||
|
||||
FROM node:20-trixie-slim AS node
|
||||
|
||||
FROM gotenberg/gotenberg:8 AS gotenberg
|
||||
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||
COMPOSER_NO_INTERACTION=1 \
|
||||
OCTANE_SERVER=frankenphp \
|
||||
TZ=UTC
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# PostgreSQL 17 (Debian trixie default) is one of the versions solidtime is tested against in CI.
|
||||
# chromium, qpdf and exiftool are what Gotenberg needs for HTML to PDF rendering.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
|
||||
chromium fonts-liberation qpdf libimage-exiftool-perl \
|
||||
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap apcu \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
|
||||
|
||||
# Gotenberg 8 (the PDF renderer, same image as in CI). solidtime only uses its Chromium HTML to PDF route, so LibreOffice
|
||||
# and pdftk are not installed: Gotenberg only checks at startup that their binaries exist, start-gotenberg.sh disables
|
||||
# the LibreOffice routes and the Chromium route does not use pdftk.
|
||||
COPY --from=gotenberg /usr/bin/gotenberg /usr/local/bin/gotenberg
|
||||
COPY --from=gotenberg /usr/bin/pdfcpu /usr/local/bin/pdfcpu
|
||||
COPY --from=gotenberg /opt/gotenberg /opt/gotenberg
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium \
|
||||
CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data \
|
||||
GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions \
|
||||
QPDF_BIN_PATH=/usr/bin/qpdf \
|
||||
EXIFTOOL_BIN_PATH=/usr/bin/exiftool \
|
||||
PDFCPU_BIN_PATH=/usr/local/bin/pdfcpu \
|
||||
LIBREOFFICE_BIN_PATH=/bin/false \
|
||||
UNOCONVERTER_BIN_PATH=/bin/false \
|
||||
PDFTK_BIN_PATH=/bin/false
|
||||
|
||||
# Database matching .env.ci: user root / password root, database laravel.
|
||||
RUN pg_ctlcluster 17 main start \
|
||||
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
|
||||
&& runuser -u postgres -- createdb -O root laravel \
|
||||
&& pg_ctlcluster 17 main stop
|
||||
|
||||
# scanner contract: the checkout lives inside the image, at /src
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
|
||||
RUN composer install --prefer-dist \
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
|
||||
RUN cp .env.ci .env \
|
||||
&& php artisan key:generate \
|
||||
&& php artisan passport:keys --force \
|
||||
&& php artisan octane:install --server=frankenphp --no-interaction \
|
||||
&& chmod +x .oss-scanner/*.sh
|
||||
|
||||
# Run the test suite so the image is known to work, but do not fail the build on test failures.
|
||||
RUN .oss-scanner/start-postgres.sh \
|
||||
&& .oss-scanner/start-gotenberg.sh \
|
||||
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
|
||||
&& kill "$(cat /tmp/gotenberg.pid)" \
|
||||
&& pg_ctlcluster 17 main stop
|
||||
@@ -1,18 +0,0 @@
|
||||
# Used instead of the root .dockerignore when building .oss-scanner/Dockerfile. The root one is tailored to the
|
||||
# production image and excludes tests, phpunit.xml etc., which the scanner needs.
|
||||
node_modules
|
||||
extensions/*/node_modules
|
||||
vendor
|
||||
.env
|
||||
public/build
|
||||
public/hot
|
||||
storage/*.key
|
||||
storage/logs/*
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
auth.json
|
||||
.DS_Store
|
||||
.idea
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the local Gotenberg server (PDF rendering) on 127.0.0.1:3000, matching GOTENBERG_URL in .env.ci.
|
||||
# Only the Chromium routes are needed by solidtime, see .oss-scanner/Dockerfile. Stop: kill "$(cat /tmp/gotenberg.pid)"
|
||||
set -euo pipefail
|
||||
|
||||
if ! curl -fs http://127.0.0.1:3000/health >/dev/null; then
|
||||
nohup gotenberg \
|
||||
--api-port=3000 \
|
||||
--libreoffice-disable-routes \
|
||||
--libreoffice-auto-start=false \
|
||||
--log-level=warn \
|
||||
>/tmp/gotenberg.log 2>&1 &
|
||||
echo $! >/tmp/gotenberg.pid
|
||||
until curl -fs http://127.0.0.1:3000/health >/dev/null; do sleep 0.5; done
|
||||
fi
|
||||
echo "Gotenberg is running on http://127.0.0.1:3000 (log: /tmp/gotenberg.log)"
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the app the way production does: Laravel Octane on FrankenPHP in worker mode, with the production Caddyfile.
|
||||
# A worker boots the app once and then serves many requests, so state kept in memory (static properties, singletons,
|
||||
# changed config, ...) survives from one request to the next. With a single worker, consecutive requests always hit the
|
||||
# same worker, so such leaks between requests (e.g. between users of different organizations) reproduce reliably.
|
||||
#
|
||||
# Usage: .oss-scanner/start-octane.sh (OCTANE_WORKERS and OCTANE_MAX_REQUESTS can be overridden)
|
||||
# Stop: php artisan octane:stop
|
||||
# After changing PHP code, run `php artisan octane:reload`: workers keep the code they booted with.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
.oss-scanner/start-postgres.sh
|
||||
.oss-scanner/start-gotenberg.sh
|
||||
|
||||
php artisan migrate --force
|
||||
if [ "$(PGPASSWORD=root psql -h 127.0.0.1 -U root -d laravel -tAc 'SELECT count(*) FROM users')" = "0" ]; then
|
||||
php artisan db:seed --force
|
||||
fi
|
||||
php artisan optimize:clear >/dev/null
|
||||
|
||||
nohup php artisan octane:frankenphp \
|
||||
--host=127.0.0.1 \
|
||||
--port=8000 \
|
||||
--workers="${OCTANE_WORKERS:-1}" \
|
||||
--max-requests="${OCTANE_MAX_REQUESTS:-10000}" \
|
||||
--caddyfile=docker/prod/deployment/octane/FrankenPHP/Caddyfile \
|
||||
>/tmp/octane.log 2>&1 &
|
||||
|
||||
until curl -fs -o /dev/null http://127.0.0.1:8000/login; do
|
||||
if ! kill -0 $! 2>/dev/null; then
|
||||
cat /tmp/octane.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "solidtime is running on http://127.0.0.1:8000 with Octane/FrankenPHP (log: /tmp/octane.log)"
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile).
|
||||
set -euo pipefail
|
||||
|
||||
pg_ctlcluster 17 main start 2>/dev/null || true
|
||||
until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done
|
||||
echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)"
|
||||
@@ -1,122 +0,0 @@
|
||||
# Threat model
|
||||
|
||||
## What this project does
|
||||
|
||||
solidtime is an open-source, multi-tenant time tracking web application (Laravel backend, Vue 3 + Inertia frontend,
|
||||
PostgreSQL). It runs as a hosted SaaS (solidtime.io) and is self-hosted by many organisations. Users belong to one or
|
||||
more **organizations**; inside an organization each member has a role: `owner`, `admin`, `manager`, `employee` or
|
||||
`placeholder` (an imported, non-login member). What each role may do is defined in `app/Service/PermissionStore.php`.
|
||||
|
||||
The most important security property is **isolation**: a user must never read or modify data of an organization they
|
||||
are not a member of, and within an organization a member must not exceed the permissions of their role (e.g. an
|
||||
employee must not see other members' time entries, billable rates, or manage members, unless the organization settings
|
||||
explicitly allow it).
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- **Super admins are fully trusted.** They are the instance operators, configured via the `SUPER_ADMINS` env
|
||||
variable, and have access to the Filament admin panel (`app/Filament`), which can view and change data of every
|
||||
organization and impersonate users. Anything a super admin can do through the panel (including XSS, SQL injection,
|
||||
SSRF or file access that is only reachable from the panel) is not a vulnerability.
|
||||
- What **is** in scope: a user who is not a super admin reaching the admin panel, or any of its actions, at all.
|
||||
- Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted.
|
||||
- Everyone else, including organization owners and admins when acting outside their own organization, is untrusted.
|
||||
|
||||
## Runtime: long-lived Octane workers
|
||||
|
||||
In production (the hosted SaaS and the official Docker image, `docker/prod/`) the app does **not** run as one PHP
|
||||
process per request. It runs on Laravel Octane with FrankenPHP in worker mode: each worker boots the application once
|
||||
and then serves many requests from different users and organizations. Anything kept in memory survives from one request
|
||||
to the next unless Octane resets it (`config/octane.php` lists what is reset). This includes static properties and
|
||||
static caches, container bindings registered with `singleton()` instead of `scoped()` (see
|
||||
`app/Providers/AppServiceProvider.php`), objects captured by those singletons, runtime `config()` / locale / timezone
|
||||
changes, macros and event listeners registered during a request, and state in third-party packages.
|
||||
|
||||
Request A leaving state behind that request B (another user, possibly of another organization) then sees or is
|
||||
affected by is in scope, rated by its impact like any other issue (see severity below). The PHPUnit suite cannot
|
||||
show this class of bug, because it boots a fresh application for every test. It has to be reproduced over HTTP against
|
||||
the Octane server, see "How to exercise it".
|
||||
|
||||
## Where untrusted input enters
|
||||
|
||||
All authenticated users, including employees of any organization and anyone who self-registers (registration is open
|
||||
by default), are untrusted.
|
||||
|
||||
- **JSON API** `routes/api.php` (`/api/v1/...`), authenticated via Passport (session cookie or personal access token).
|
||||
Most routes are scoped by `{organization}` and authorised in the controllers / form requests.
|
||||
- **Public, unauthenticated** endpoints: `GET /api/v1/public/reports` (shared reports, accessed by a secret), login,
|
||||
registration, password reset, email verification, organization invitation acceptance (`routes/web.php`).
|
||||
- **Web / Inertia routes** `routes/web.php` and Fortify/Jetstream actions in `app/Actions`.
|
||||
- **Imports** (`app/Service/Import/Importers`): user-uploaded CSV and ZIP files from Toggl, Clockify, Harvest,
|
||||
generic CSV and solidtime's own export format. ZIP handling is in `ZipImportHelper.php`.
|
||||
- **Exports / reports** (`app/Service/Export`, `app/Service/ReportExport`): CSV/XLSX/ODS and PDF. PDFs are rendered by
|
||||
sending HTML to a Gotenberg (headless Chromium) service, so user-controlled content in that HTML matters.
|
||||
- **OAuth** (Passport) authorization and token endpoints.
|
||||
- **Filament admin panel** (`app/Filament`): only its access control is in scope (see Trust boundaries).
|
||||
|
||||
## Components that matter most / least
|
||||
|
||||
Most important: organization scoping and role checks in the API controllers, form requests (`app/Http/Requests`),
|
||||
`PermissionStore`, public report sharing, invitations and member management (role changes, ownership transfer, member
|
||||
merge), authentication flows (Fortify, 2FA, email change, API tokens), import parsing.
|
||||
|
||||
Less important / out of scope:
|
||||
- `extensions/` is empty in this repository (proprietary modules are not part of the open-source code).
|
||||
- `docker/`, `k8s/`, `e2e/`, `playwright/`, `docs/` and developer tooling.
|
||||
- Third-party dependencies in `vendor/` and `node_modules/`, unless solidtime uses them in an unsafe way.
|
||||
|
||||
## How to exercise it
|
||||
|
||||
- `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`).
|
||||
- `.oss-scanner/start-gotenberg.sh` starts the local Gotenberg server (PDF rendering via headless Chromium) on
|
||||
http://127.0.0.1:3000, so the PDF export code path, including what Chromium does with the rendered HTML, can be
|
||||
exercised.
|
||||
- `php artisan test` runs the PHPUnit suite (start PostgreSQL and Gotenberg first); all tests are expected to pass. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users,
|
||||
organizations and members with factories and call the API with a given role; they are the quickest way to write a
|
||||
reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`.
|
||||
- To run the app like production: `.oss-scanner/start-octane.sh` (http://127.0.0.1:8000). It starts PostgreSQL and
|
||||
Gotenberg, migrates (and seeds an empty database, see `database/seeders/DatabaseSeeder.php` for the users) and runs
|
||||
Octane/FrankenPHP with the production Caddyfile and a **single worker**, so consecutive requests always hit the same
|
||||
worker and leaks between requests reproduce reliably. Workers keep the code they booted with: after changing PHP
|
||||
code run `php artisan octane:reload`. Stop it with `php artisan octane:stop`. Note that the test suite and the app
|
||||
share the same database, so `php artisan test` wipes the app's data.
|
||||
- A reproducer for a leak between requests is a script that sends request A (e.g. as a member of organization X) and
|
||||
then request B (as a user of organization Y) to the running Octane server and shows that B observes A's state. API
|
||||
requests can be authenticated with a personal access token, e.g. created with
|
||||
`php artisan tinker --execute="echo App\Models\User::where('email', '...')->first()->createToken('t')->accessToken;"`.
|
||||
- There is no network: mail delivery is not available (mail uses the `array` driver in tests), and remote resources
|
||||
referenced by PDF templates (e.g. fonts from fonts.bunny.net) fail to load, so PDFs fall back to local fonts.
|
||||
|
||||
## How we rate severity
|
||||
|
||||
- **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution;
|
||||
SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a
|
||||
member of, including through state leaking between requests in an Octane worker.
|
||||
- **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only
|
||||
actions); access to data the role must not see (other members' time entries, billable rates, member emails) when
|
||||
the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF
|
||||
rendering or imports; account takeover requiring user interaction.
|
||||
- **Medium**: information disclosure with limited impact, CSRF on state-changing endpoints, issues requiring an
|
||||
unusual but realistic configuration, denial of service by a single authenticated request (e.g. pathological
|
||||
import file).
|
||||
- **Low**: everything else with real security impact.
|
||||
|
||||
## Anything to leave alone
|
||||
|
||||
Please do not report (see also `SECURITY.md`):
|
||||
- Theoretical findings without a working reproducer.
|
||||
- Missing or weak security headers in isolation; TLS / mail DNS configuration.
|
||||
- Self-XSS; CSRF on non-state-changing endpoints (logout, theme).
|
||||
- CSV / spreadsheet formula injection in exports.
|
||||
- Owners or admins acting destructively within their own organization.
|
||||
- Anything requiring direct DB, shell or filesystem access on a self-hosted instance.
|
||||
- Anything that requires being a super admin, including issues inside the Filament admin panel.
|
||||
- Missing OAuth scope enforcement (not implemented yet).
|
||||
- Rate-limit tuning and generic DoS through volume of requests.
|
||||
|
||||
## Reports and patches
|
||||
|
||||
Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of
|
||||
`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue (for leaks between requests: a script against
|
||||
`.oss-scanner/start-octane.sh` instead), and a minimal patch that follows the existing patterns
|
||||
(authorisation in form requests/controllers via `PermissionStore`).
|
||||
@@ -59,6 +59,14 @@ class SelfHostDatabaseConsistency extends Command
|
||||
->get();
|
||||
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
|
||||
|
||||
// End of time entries can not be before the start
|
||||
$problems = DB::table('time_entries')
|
||||
->select(['id'])
|
||||
->whereNotNull('end')
|
||||
->whereColumn('end', '<', 'start')
|
||||
->get();
|
||||
$this->logProblems($problems, 'Time entries have an end that is before the start', $hadAProblem);
|
||||
|
||||
// Every user needs to be a member of at least one organization
|
||||
$problems = DB::table('users')
|
||||
->select(['users.id as id'])
|
||||
|
||||
@@ -13,9 +13,11 @@ use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\PermissionStore;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ConditionalRules;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -90,12 +92,32 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'start' => [
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
|
||||
// If the payload does not contain an end, the start needs to be validated against the persisted end
|
||||
if ($this->has('end') || $timeEntry?->end === null) {
|
||||
return;
|
||||
}
|
||||
$start = $this->parseDate($value);
|
||||
if ($start !== null && $start->gt($timeEntry->end)) {
|
||||
$fail('The start field must be a date before or equal to end.');
|
||||
}
|
||||
},
|
||||
],
|
||||
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
|
||||
'end' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'after_or_equal:start',
|
||||
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
|
||||
// If the payload does not contain a start, the end needs to be validated against the persisted start
|
||||
if ($this->has('start') || $timeEntry === null) {
|
||||
return;
|
||||
}
|
||||
$end = $this->parseDate($value);
|
||||
if ($end !== null && $end->lt($timeEntry->start)) {
|
||||
$fail('The end field must be a date after or equal to start.');
|
||||
}
|
||||
},
|
||||
],
|
||||
// Whether time entry is billable
|
||||
'billable' => [
|
||||
@@ -137,4 +159,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseDate(mixed $value): ?Carbon
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||
}
|
||||
if ($end->lt($start)) {
|
||||
throw new ImportException('End ("'.$endStr.'") is before start ("'.$startStr.'")');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
|
||||
@@ -155,6 +155,9 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
||||
if ($end === null) {
|
||||
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
||||
}
|
||||
if ($end->lt($start)) {
|
||||
throw new ImportException('Value of end ("'.$record['end'].'") is before start ("'.$record['start'].'")');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
|
||||
@@ -136,6 +136,9 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
|
||||
}
|
||||
$hours = (float) $hoursField;
|
||||
if ($hours < 0) {
|
||||
throw new ImportException('Hours ("'.$record['Hours'].'") is negative');
|
||||
}
|
||||
$timeEntry->start = $date->copy()->startOfDay()->utc();
|
||||
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
|
||||
@@ -280,6 +280,9 @@ class SolidtimeImporter extends DefaultImporter
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||
}
|
||||
if ($end->lt($start)) {
|
||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is before start date ("'.$timeEntryRow['start'].'")');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
} else {
|
||||
$timeEntry->end = null;
|
||||
|
||||
@@ -139,6 +139,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
|
||||
}
|
||||
if ($end->lt($start)) {
|
||||
throw new ImportException('End ("'.$record['End date'].' '.$record['End time'].'") is before start ("'.$record['Start date'].' '.$record['Start time'].'")');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
|
||||
737
composer.lock
generated
737
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,10 @@
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.10"
|
||||
"ref": "v0.0.9"
|
||||
},
|
||||
"Auditing": {
|
||||
"repository": "solidtime-io/extension-auditing",
|
||||
"ref": "v0.0.5"
|
||||
"ref": "v0.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
@@ -158,4 +159,26 @@ class SelfHostDatabaseConsistencyCommandTest extends TestCaseWithDatabase
|
||||
$output = Artisan::output();
|
||||
$this->assertSame("Consistency problem: Users have a current organization that they are not a member of\n - ".$user1->user->getKey()."\n", $output);
|
||||
}
|
||||
|
||||
public function test_checks_that_end_of_time_entries_is_not_before_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithRole(Role::Owner);
|
||||
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
|
||||
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
|
||||
'end' => Carbon::parse('2026-08-01T02:59:55Z'),
|
||||
]);
|
||||
TimeEntry::factory()->forMember($user->member)->create([
|
||||
'start' => Carbon::parse('2026-08-01T04:00:00Z'),
|
||||
'end' => Carbon::parse('2026-08-01T04:00:00Z'),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
|
||||
|
||||
// Assert
|
||||
$this->assertSame(Command::FAILURE, $exitCode);
|
||||
$output = Artisan::output();
|
||||
$this->assertSame("Consistency problem: Time entries have an end that is before the start\n - ".$timeEntry->getKey()."\n", $output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2961,6 +2961,101 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_validation_fails_if_only_end_is_sent_and_it_is_before_the_persisted_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
|
||||
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
|
||||
'end' => Carbon::parse('2026-08-01T03:01:00Z'),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'end' => '2026-08-01T02:59:55Z',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'end' => 'The end field must be a date after or equal to start.',
|
||||
]);
|
||||
$timeEntry->refresh();
|
||||
$this->assertSame('2026-08-01T03:01:00Z', $timeEntry->end->toIso8601ZuluString());
|
||||
}
|
||||
|
||||
public function test_update_endpoint_validation_fails_if_only_start_is_sent_and_it_is_after_the_persisted_end(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
|
||||
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
|
||||
'end' => Carbon::parse('2026-08-01T03:01:00Z'),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'start' => '2026-08-01T03:01:05Z',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'start' => 'The start field must be a date before or equal to end.',
|
||||
]);
|
||||
$timeEntry->refresh();
|
||||
$this->assertSame('2026-08-01T03:00:00Z', $timeEntry->start->toIso8601ZuluString());
|
||||
}
|
||||
|
||||
public function test_update_endpoint_allows_updating_only_start_of_running_time_entry(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'start' => '2026-08-01T03:00:00Z',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$timeEntry->refresh();
|
||||
$this->assertSame('2026-08-01T03:00:00Z', $timeEntry->start->toIso8601ZuluString());
|
||||
}
|
||||
|
||||
public function test_update_endpoint_allows_updating_only_end_if_it_is_after_the_persisted_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
]);
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
|
||||
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
|
||||
'end' => Carbon::parse('2026-08-01T03:01:00Z'),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'end' => '2026-08-01T03:00:00Z',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$timeEntry->refresh();
|
||||
$this->assertSame('2026-08-01T03:00:00Z', $timeEntry->end->toIso8601ZuluString());
|
||||
}
|
||||
|
||||
public function test_update_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -231,4 +231,29 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(0, Tag::query()->count());
|
||||
$this->assertSame(0, Client::query()->count());
|
||||
}
|
||||
|
||||
public function test_import_fails_if_end_is_before_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = <<<'CSV'
|
||||
"Project","Client","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
||||
"Project","Client","Working hard","","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:30:00 AM","03/04/2024","10:00:00 AM","00:30:00","0.50","0.00","0.00"
|
||||
CSV;
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('End ("03/04/2024 10:00:00 AM") is before start ("03/04/2024 10:30:00 AM")', $e->getMessage());
|
||||
$this->assertSame(0, TimeEntry::query()->count());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\GenericTimeEntriesImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
@@ -89,4 +90,27 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
public function test_import_fails_if_end_is_before_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new GenericTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
|
||||
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T10:23:00Z","2024-03-04T09:23:00Z","","Peter Tester","peter.test@email.test"';
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('Value of end ("2024-03-04T09:23:00Z") is before start ("2024-03-04T10:23:00Z")', $e->getMessage());
|
||||
$this->assertSame(0, TimeEntry::query()->count());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,4 +105,27 @@ class HarvestTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_hours_are_negative(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new HarvestTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = "Date,Client,Project,Project Code,Task,Notes,Hours,Billable?,Invoiced?,Approved?,First Name,Last Name,Roles,Employee?,Billable Rate,Billable Amount,Cost Rate,Cost Amount,Currency,External Reference URL\n".
|
||||
'2024-03-04,,Project without Client,,,"","-2,0",No,No,No,Peter,Tester,,Yes,"100,0","2.000,0","0,0","0,0",Euro - EUR,';
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('Hours ("-2,0") is negative', $e->getMessage());
|
||||
$this->assertSame(0, TimeEntry::query()->count());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ use App\Service\Import\Importers\SolidtimeImporter;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use ZipArchive;
|
||||
|
||||
#[CoversClass(SolidtimeImporter::class)]
|
||||
#[CoversClass(ImportException::class)]
|
||||
@@ -177,4 +179,32 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
||||
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
|
||||
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_end_of_time_entry_is_before_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($zipPath);
|
||||
$timeEntries = $zip->getFromName('time_entries.csv');
|
||||
$timeEntries = Str::replaceFirst(',2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,', ',2024-03-04T09:23:52Z,2024-03-04T08:23:52Z,', $timeEntries);
|
||||
$zip->addFromString('time_entries.csv', $timeEntries);
|
||||
$zip->close();
|
||||
$importer = new SolidtimeImporter;
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents($zipPath);
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('End date ("2024-03-04T08:23:52Z") is before start date ("2024-03-04T09:23:52Z")', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,27 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);
|
||||
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_end_is_before_start(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new TogglTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = "User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR)\n".
|
||||
'Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,09:23:52,-01:00:00,"",';
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('End ("2024-03-04 09:23:52") is before start ("2024-03-04 10:23:52")', $e->getMessage());
|
||||
$this->assertSame(0, TimeEntry::query()->count());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user