Compare commits

..

5 Commits

Author SHA1 Message Date
Constantin Graf
c5bcf98b84 Delete running time entries of placeholder when merging members
The running time entries of the merged member could give the destination
member a second running time entry. The placeholder is deleted after the
merge, so its running time entries can not stay with it.
2026-10-13 18:04:11 +02:00
Constantin Graf
0bfeab156c Skip running time entries in solidtime import
Imported running time entries could give a user a second running time
entry, for example when an export is imported again while a timer runs.
2026-10-13 18:04:11 +02:00
Constantin Graf
332edcb047 Reject changing the member of running time entries
Moving a running time entry to another member could give that member's
user a second running time entry. Stopping the time entry and changing
the member in the same request is still allowed.
2026-10-13 17:08:25 +02:00
Constantin Graf
c791057f25 Allow only one running time entry per user across organizations
The running time entry check in the store endpoint was scoped to the
member, so a user could start a time entry in each of their
organizations. The active time entry endpoint and the database
consistency check expect only one running time entry per user.
2026-10-09 12:58:15 +02:00
Constantin Graf
4a4acb2214 Lock creation of running time entries to prevent race condition
Concurrent requests could both pass the running time entry check and
create more than one running time entry for the same member.
2026-10-09 12:58:06 +02:00
36 changed files with 407 additions and 658 deletions

View File

@@ -5,7 +5,7 @@ permissions:
jobs: jobs:
phpunit-extensions: phpunit-extensions:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 15
strategy: strategy:
matrix: matrix:
postgres_version: [ 15, 16, 17 ] postgres_version: [ 15, 16, 17 ]
@@ -132,8 +132,5 @@ jobs:
php artisan key:generate php artisan key:generate
php artisan passport:keys 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 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

View File

@@ -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

View File

@@ -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

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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`).

View File

@@ -5,12 +5,8 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Providers\FortifyServiceProvider;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords; use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords class ResetUserPassword implements ResetsUserPasswords
@@ -24,16 +20,6 @@ class ResetUserPassword implements ResetsUserPasswords
*/ */
public function reset(User $user, array $input): void public function reset(User $user, array $input): void
{ {
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
/** @var PasswordBroker $broker */
$broker = Password::broker(config('fortify.passwords'));
$broker->deleteToken($user);
throw ValidationException::withMessages([
'email' => [__('This password reset link is invalid.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class TimeEntryMemberCanNotBeChangedWhileRunningApiException extends ApiException
{
public const string KEY = 'time_entry_member_can_not_be_changed_while_running';
public function report(): bool
{
return true;
}
}

View File

@@ -11,6 +11,7 @@ use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
@@ -51,6 +52,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -613,7 +615,24 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:create:all'); $this->checkPermission($organization, 'time-entries:create:all');
} }
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { // Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry
$lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null;
$lock?->block(5);
try {
return $this->storeTimeEntry($organization, $member, $request);
} finally {
$lock?->release();
}
}
/**
* @throws TimeEntryStillRunningApiException
*/
private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource
{
// A user can only have one running time entry, across all organizations
if ($request->input('end') === null && TimeEntry::query()->where('user_id', $member->user_id)->whereNull('end')->exists()) {
throw new TimeEntryStillRunningApiException; throw new TimeEntryStillRunningApiException;
} }
@@ -650,7 +669,7 @@ class TimeEntryController extends Controller
* *
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first. * To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
* *
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
*/ */
@@ -676,6 +695,11 @@ class TimeEntryController extends Controller
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end; $effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry); $this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
// The new member could already have a running time entry
if ($effectiveEnd === null && $newMember !== null && $newMember->getKey() !== $timeEntry->member_id) {
throw new TimeEntryMemberCanNotBeChangedWhileRunningApiException;
}
$oldProject = $timeEntry->project; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;
@@ -801,6 +825,12 @@ class TimeEntryController extends Controller
continue; continue;
} }
// The new member could already have a running time entry
if ($newMember !== null && $timeEntry->end === null && $newMember->getKey() !== $timeEntry->member_id) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;

View File

@@ -160,23 +160,13 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF'; return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
} }
public function isSuperAdmin(): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->is_placeholder === false return $this->is_placeholder === false
&& in_array($this->email, config('auth.super_admins', []), true) && in_array($this->email, config('auth.super_admins', []), true)
&& $this->hasVerifiedEmail(); && $this->hasVerifiedEmail();
} }
public function hasLocalPassword(): bool
{
return is_string($this->password) && $this->password !== '';
}
public function canAccessPanel(Panel $panel): bool
{
return $this->isSuperAdmin();
}
public function isMemberOfOrganization(Organization $organization): bool public function isMemberOfOrganization(Organization $organization): bool
{ {
if ($this->relationLoaded('organizations')) { if ($this->relationLoaded('organizations')) {

View File

@@ -26,7 +26,6 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module; use Nwidart\Modules\Facades\Module;
use Nwidart\Modules\Laravel\Module as LaravelModule;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
@@ -94,77 +93,22 @@ class AdminPanelProvider extends PanelProvider
$modules = Module::allEnabled(); $modules = Module::allEnabled();
foreach ($modules as $module) { foreach ($modules as $module) {
$moduleNamespace = $this->getModuleAppNamespace($module);
$panel->discoverResources( $panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'), in: module_path($module->getName(), 'app/Filament/Resources'),
for: $moduleNamespace.'\\Filament\\Resources' for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
); );
$panel->discoverPages( $panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'), in: module_path($module->getName(), 'app/Filament/Pages'),
for: $moduleNamespace.'\\Filament\\Pages' for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
); );
$panel->discoverWidgets( $panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'), in: module_path($module->getName(), 'app/Filament/Widgets'),
for: $moduleNamespace.'\\Filament\\Widgets' for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
); );
} }
return $panel; return $panel;
} }
/** @var array<string, string> Cache of module name => resolved app namespace. */
private static array $moduleAppNamespaces = [];
private function getModuleAppNamespace(LaravelModule $module): string
{
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
}
/**
* Resolve the PHP namespace mapped to a module's app/ directory so the
* Filament panel can discover its Resources/Pages/Widgets under the right
* namespace.
*
* Two module layouts currently coexist in this repo:
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
* app/ — e.g. "Extensions\SSO\" => app/, so classes are
* Extensions\SSO\Filament\... (this is the current convention).
* - the older layout: an "...\App" namespace maps to app/ — e.g.
* "Extensions\Billing\App\" => app/, so classes are
* Extensions\Billing\App\Filament\...
*
* The package's own namespace derivation assumes the v12 (bare) layout and
* would mis-resolve the legacy modules, so we read each module's composer
* PSR-4 map and use whichever namespace actually points at app/. The legacy
* "...\App" shape is only a fallback for when composer is missing/unreadable.
* Once every module adopts the bare layout this collapses to
* config('modules.namespace').'\\'.$module->getName().
*/
private function resolveModuleAppNamespace(LaravelModule $module): string
{
$fallback = 'Extensions\\'.$module->getName().'\\App';
$composerPath = module_path($module->getName(), 'composer.json');
$psr4 = [];
if (is_file($composerPath)) {
$composer = json_decode((string) file_get_contents($composerPath), true);
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
}
foreach ((array) $psr4 as $namespace => $path) {
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
return rtrim($namespace, '\\');
}
}
return $fallback;
}
private function normalizeComposerPath(mixed $path): string
{
return trim(str_replace('\\', '/', (string) $path), '/');
}
} }

View File

@@ -25,73 +25,6 @@ use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
/**
* Dummy bcrypt hash compared against when no user matches the submitted
* email. Hash::check is run against it so login takes the same time whether
* or not the email exists — otherwise an unknown email would skip the
* (deliberately slow) hash and return faster, letting an attacker enumerate
* registered accounts by timing the response. The plaintext is irrelevant:
* it is only ever checked against attacker-supplied input and never matches.
*/
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
/**
* Authorization rules applied AFTER the password is verified. Each rule
* receives the authenticated user + request and returns whether the login
* may proceed; any rule returning false denies it. This is an extension
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
* instead of replacing this credential check — which would silently drift
* from the host logic the next time it changes.
*
* @var array<int, \Closure(User, Request): bool>
*/
protected static array $loginRules = [];
/**
* Authorization rules applied before a password reset is completed. Rules
* receive the user being reset + submitted input and return whether the
* local reset flow may set a new password for that account.
*
* @var array<int, \Closure(User, array<string, mixed>): bool>
*/
protected static array $passwordResetRules = [];
/**
* Register an additional rule that gates password login (see $loginRules).
*
* @param \Closure(User, Request): bool $rule
*/
public static function authenticateUsingRule(\Closure $rule): void
{
static::$loginRules[] = $rule;
}
/**
* Register an additional rule that gates password reset completion.
*
* @param \Closure(User, array<string, mixed>): bool $rule
*/
public static function resetPasswordUsingRule(\Closure $rule): void
{
static::$passwordResetRules[] = $rule;
}
/**
* Check whether the given user may complete the local password reset flow.
*
* @param array<string, mixed> $input
*/
public static function canResetPassword(User $user, array $input = []): bool
{
foreach (static::$passwordResetRules as $rule) {
if (! $rule($user, $input)) {
return false;
}
}
return true;
}
/** /**
* Register any application services. * Register any application services.
*/ */
@@ -160,23 +93,7 @@ class FortifyServiceProvider extends ServiceProvider
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->first(); ->first();
// Always run the hash check — against the real hash, or a dummy when if ($user !== null && Hash::check($request->password, $user->password)) {
// there is no user — so login timing is identical either way (see
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
// have password = null) fail here, so they cannot password-login.
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
if ($user !== null && $passwordIsValid) {
// Credentials are valid; now apply any registered authorization
// rules (e.g. SSO enforcement may still block password login).
foreach (static::$loginRules as $rule) {
if (! $rule($user, $request)) {
return null;
}
}
return $user; return $user;
} }

View File

@@ -211,6 +211,10 @@ class SolidtimeImporter extends DefaultImporter
// Time entries // Time entries
foreach ($timeEntriesReader as $timeEntryRow) { foreach ($timeEntriesReader as $timeEntryRow) {
// Running time entries are skipped, since the user could already have a running time entry
if ($timeEntryRow['end'] === '') {
continue;
}
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']); $userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']); $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
$member = $this->memberImportHelper->getModelById($memberId); $member = $this->memberImportHelper->getModelById($memberId);
@@ -271,19 +275,15 @@ class SolidtimeImporter extends DefaultImporter
} }
$timeEntry->start = $start->utc(); $timeEntry->start = $start->utc();
if ($timeEntryRow['end'] !== '') { try {
try { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC'); } catch (InvalidFormatException) {
} catch (InvalidFormatException) { throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
} else {
$timeEntry->end = null;
} }
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
if ($timeEntryRow['still_active_email_sent_at'] !== '') { if ($timeEntryRow['still_active_email_sent_at'] !== '') {
try { try {

View File

@@ -159,6 +159,13 @@ class MemberService
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
{ {
// Running time entries are deleted instead of moved, since $toMember could already have a running time entry
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($fromMember, 'member')
->whereNull('end')
->delete();
// Time entries // Time entries
TimeEntry::query() TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')

View File

@@ -49,56 +49,6 @@ class UserService
} }
$user->save(); $user->save();
$this->createDefaultOrganizationForUser(
$user,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
return $user;
}
/**
* Create a user without a password (e.g. provisioned via SSO). Such users
* can only authenticate through a linked identity provider.
*/
public function createPasswordlessUser(
string $name,
string $email,
string $timezone,
Weekday $weekStart,
?string $currency,
bool $verifyEmail = false
): User {
$user = new User;
$user->name = $name;
$user->email = strtolower($email);
$user->password = null;
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$this->createDefaultOrganizationForUser($user, $currency);
return $user;
}
private function createDefaultOrganizationForUser(
User $user,
?string $currency,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): void {
$organizations = app(InvitationService::class)->processAcceptedInvitations($user); $organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) { if ($organizations->isEmpty()) {
@@ -115,6 +65,8 @@ class UserService
); );
$this->switchCurrentOrganization($user, $organization); $this->switchCurrentOrganization($user, $organization);
} }
return $user;
} }
/** /**

View File

@@ -9,10 +9,10 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.10" "ref": "v0.0.9"
}, },
"Auditing": { "Auditing": {
"repository": "solidtime-io/extension-auditing", "repository": "solidtime-io/extension-auditing",
"ref": "v0.0.5" "ref": "v0.0.4"
} }
} }

View File

@@ -19,6 +19,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException; use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
@@ -31,6 +32,7 @@ return [
TimeEntryStillRunningApiException::KEY => 'Time entry is still running', TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted', TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
TimeEntryMemberCanNotBeChangedWhileRunningApiException::KEY => 'The member of a running time entry can not be changed',
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used', InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization', UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project', UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',

View File

@@ -7,16 +7,10 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
withDefaults( defineProps({
defineProps<{ canResetPassword: Boolean,
canResetPassword?: boolean; status: String,
status?: string; });
}>(),
{
canResetPassword: false,
status: '',
}
);
const form = useForm({ const form = useForm({
email: '', email: '',
@@ -34,8 +28,8 @@ const submit = () => {
}; };
const page = usePage<{ const page = usePage<{
flash?: { flash: {
message?: string; message: string;
}; };
}>(); }>();
</script> </script>
@@ -67,9 +61,6 @@ const page = usePage<{
{{ page.props.flash?.message }} {{ page.props.flash?.message }}
</div> </div>
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
<slot name="error" />
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<Field> <Field>
<FieldLabel for="email">Email</FieldLabel> <FieldLabel for="email">Email</FieldLabel>
@@ -112,8 +103,5 @@ const page = usePage<{
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
<slot name="alternatives" />
</AuthenticationCard> </AuthenticationCard>
</template> </template>

View File

@@ -10,73 +10,35 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { type DefineComponent } from 'vue'; import { type DefineComponent } from 'vue';
import { setupPrefetching } from '@/utils/prefetch'; import { setupPrefetching } from '@/utils/prefetch';
interface ExtensionManifest {
name?: string;
alias?: string;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia(); const pinia = createPinia();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
eager: true,
import: 'default',
}) as Record<string, ExtensionManifest>;
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
const extensionPages = import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/**/*.vue',
'!**/BillingPortal.vue',
]);
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
(directories, [path, manifest]) => {
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
const extensionDirectory = match?.[1];
if (extensionDirectory === undefined) {
return directories;
}
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
if (typeof key !== 'string' || key === '') {
continue;
}
directories[key] = extensionDirectory;
directories[key.toLowerCase()] = extensionDirectory;
}
return directories;
},
{}
);
function resolveExtensionDirectory(moduleName: string): string {
return (
extensionDirectories[moduleName] ??
extensionDirectories[moduleName.toLowerCase()] ??
moduleName
);
}
createInertiaApp({ createInertiaApp({
title: (title) => `${title} - ${appName}`, title: (title) => `${title} - ${appName}`,
resolve: (name) => { resolve: (name) => {
// "Module::Page" (both halves present) resolves to that extension's page if (name.includes('Invoicing::')) {
// directory; everything else is a host page under resources/js/Pages. const [module, page] = name.split('::');
const [module, ...pageSegments] = name.split('::');
const page = pageSegments.join('::');
if (module && page) { const pagePath = module
const extensionDirectory = resolveExtensionDirectory(module); ? `../../extensions/${module}/resources/js/Pages/${page}.vue`
const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`; : `./Pages/${page}.vue`;
return resolvePageComponent(pagePath, extensionPages); // BillingPortal is a Vue 2 Component and therefore should not be imported
const pages = module
? import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/*.vue',
'!**/BillingPortal.vue',
])
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
return resolvePageComponent(pagePath, pages);
} else {
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
} }
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
}, },
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
const app = createApp({ render: () => h(App, props) }); const app = createApp({ render: () => h(App, props) });

View File

@@ -0,0 +1,3 @@
id,name,organization_id,archived_at,created_at,updated_at
b4187a44-41f4-46d7-8460-f15a25b3aad6,"Big Company",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,"Other Company (Archived)",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name organization_id archived_at created_at updated_at
2 b4187a44-41f4-46d7-8460-f15a25b3aad6 Big Company ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72 Other Company (Archived) ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1,2 @@
id,user_id,name,email,organization_id,billable_rate,role,created_at,updated_at
06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,"Peter Tester",peter.test@email.test,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id user_id name email organization_id billable_rate role created_at updated_at
2 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c Peter Tester peter.test@email.test ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 admin 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1 @@
{"id":"d6a324ee-58d5-4096-8069-c63bd55608f7","version":"1.0","organizations":["ee5a8cd6-312f-4ae6-b044-e2014f09ecc2"],"exported_at":"2024-08-26T18:21:59Z"}

View File

@@ -0,0 +1 @@
id,email,organization_id,role,created_at,updated_at
1 id email organization_id role created_at updated_at

View File

@@ -0,0 +1,2 @@
id,name,billable_rate,currency,created_at,updated_at
ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,"ACME Corp",,EUR,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name billable_rate currency created_at updated_at
2 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 ACME Corp EUR 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1,2 @@
id,billable_rate,project_id,user_id,member_id,created_at,updated_at
180a1a98-2f1c-4596-86e4-63a6be0d7b1d,10002,06e79ec4-33f8-4730-804c-d03c014991d1,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,06e6e605-86bd-417b-b75d-02f671e5d520,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id billable_rate project_id user_id member_id created_at updated_at
2 180a1a98-2f1c-4596-86e4-63a6be0d7b1d 10002 06e79ec4-33f8-4730-804c-d03c014991d1 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c 06e6e605-86bd-417b-b75d-02f671e5d520 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1,4 @@
id,name,color,billable_rate,is_public,client_id,organization_id,is_billable,archived_at,created_at,updated_at
06e79ec4-33f8-4730-804c-d03c014991d1,"Project for Big Company",#ec407a,10001,false,b4187a44-41f4-46d7-8460-f15a25b3aad6,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
622c74a9-7e64-44c2-9426-2a37ac738206,"Project without Client",#ef5350,,false,,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
aa831162-dbb2-4cfe-bfe0-5e3a252c66f0,"Project (Archived)",#6a407f,,true,e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,2024-08-25T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name color billable_rate is_public client_id organization_id is_billable archived_at created_at updated_at
2 06e79ec4-33f8-4730-804c-d03c014991d1 Project for Big Company #ec407a 10001 false b4187a44-41f4-46d7-8460-f15a25b3aad6 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 true 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 622c74a9-7e64-44c2-9426-2a37ac738206 Project without Client #ef5350 false ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
4 aa831162-dbb2-4cfe-bfe0-5e3a252c66f0 Project (Archived) #6a407f true e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 true 2024-08-25T10:00:00Z 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1,3 @@
id,name,organization_id,created_at,updated_at
2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7,Development,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
bf6c0ac5-2587-474b-8983-40bb3ea8002f,Backend,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name organization_id created_at updated_at
2 2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7 Development ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 bf6c0ac5-2587-474b-8983-40bb3ea8002f Backend ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1,3 @@
id,name,project_id,organization_id,done_at,created_at,updated_at
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 1",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 2",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-24T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name project_id organization_id done_at created_at updated_at
2 b49688a0-94f3-4cb3-9ca1-5003de955fb0 Task 1 06e79ec4-33f8-4730-804c-d03c014991d1 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 b49688a0-94f3-4cb3-9ca1-5003de955fb0 Task 2 06e79ec4-33f8-4730-804c-d03c014991d1 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-24T10:00:00Z 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -0,0 +1,5 @@
id,description,start,end,billable_rate,billable,type,member_id,user_id,organization_id,client_id,project_id,task_id,tags,is_imported,still_active_email_sent_at,created_at,updated_at
00aae3be-18fc-462d-bee4-350fb605b2f3,,2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,,false,,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,"[""2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7"",""bf6c0ac5-2587-474b-8983-40bb3ea8002f""]",false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1c7a905d-aa12-4d08-bc41-7e92577e7cdf,"Working hard",2024-03-04T09:23:00Z,2024-03-04T10:23:01Z,,true,work,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,b4187a44-41f4-46d7-8460-f15a25b3aad6,06e79ec4-33f8-4730-804c-d03c014991d1,b49688a0-94f3-4cb3-9ca1-5003de955fb0,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
9f2f5b83-3a6b-4a9c-8d21-4f1e5cf1b7aa,"Lunch break",2024-03-04T12:00:00Z,2024-03-04T12:30:00Z,,false,break,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
5d0e8c4a-7b1f-4e2a-9c3d-8a6b2f1e4c7d,"Still running",2024-03-04T13:00:00Z,,,true,work,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id description start end billable_rate billable type member_id user_id organization_id client_id project_id task_id tags is_imported still_active_email_sent_at created_at updated_at
2 00aae3be-18fc-462d-bee4-350fb605b2f3 2024-03-04T09:23:52Z 2024-03-04T09:23:52Z false 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 ["2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7","bf6c0ac5-2587-474b-8983-40bb3ea8002f"] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 1c7a905d-aa12-4d08-bc41-7e92577e7cdf Working hard 2024-03-04T09:23:00Z 2024-03-04T10:23:01Z true work 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 b4187a44-41f4-46d7-8460-f15a25b3aad6 06e79ec4-33f8-4730-804c-d03c014991d1 b49688a0-94f3-4cb3-9ca1-5003de955fb0 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
4 9f2f5b83-3a6b-4a9c-8d21-4f1e5cf1b7aa Lunch break 2024-03-04T12:00:00Z 2024-03-04T12:30:00Z false break 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
5 5d0e8c4a-7b1f-4e2a-9c3d-8a6b2f1e4c7d Still running 2024-03-04T13:00:00Z true work 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -390,6 +390,40 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_merge_into_deletes_running_time_entries_of_source_member(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
$runningTimeEntrySource = TimeEntry::factory()->forOrganization($data->organization)->forMember($memberSource)->active()->create();
TimeEntry::factory()->forOrganization($data->organization)->forMember($memberSource)->createMany(2);
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
$runningTimeEntryDestination = TimeEntry::factory()->forOrganization($data->organization)->forMember($memberDestination)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $memberSource->getKey(),
]);
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $runningTimeEntrySource->getKey(),
]);
$memberDestination->refresh();
$this->assertCount(3, $memberDestination->timeEntries);
$this->assertSame([$runningTimeEntryDestination->getKey()], TimeEntry::query()->whereBelongsTo($userDestination, 'user')->whereNull('end')->pluck('id')->all());
}
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
{ {
// Arrange // Arrange

View File

@@ -24,7 +24,9 @@ use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -2444,6 +2446,77 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('error', true); $response->assertJsonPath('error', true);
} }
public function test_store_endpoint_fails_if_user_already_has_active_time_entry_in_another_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$otherOrganization = Organization::factory()->create();
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($data->user)->create();
TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->toIso8601ZuluString(),
'end' => null,
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('key', 'time_entry_still_running');
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($data->organization, 'organization')->count());
}
public function test_store_endpoint_releases_running_time_entry_lock_after_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->toIso8601ZuluString(),
'end' => null,
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertTrue(Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get());
}
public function test_store_endpoint_waits_for_running_time_entry_lock_and_fails_if_it_is_not_released(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get();
$this->withoutExceptionHandling();
Passport::actingAs($data->user);
// Act
try {
$this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->toIso8601ZuluString(),
'end' => null,
'member_id' => $data->member->getKey(),
]);
$this->fail('Expected LockTimeoutException');
} catch (LockTimeoutException) {
// Assert
$this->assertSame(0, TimeEntry::query()->count());
}
}
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
{ {
// Arrange // Arrange
@@ -3164,6 +3237,80 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_endpoint_fails_if_member_of_running_time_entry_is_changed(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
$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()]), [
'member_id' => $otherMember->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('key', 'time_entry_member_can_not_be_changed_while_running');
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'member_id' => $data->member->getKey(),
'user_id' => $data->user->getKey(),
]);
}
public function test_update_endpoint_can_update_running_time_entry_with_unchanged_member_id(): 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()]), [
'member_id' => $data->member->getKey(),
'description' => 'New description',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'description' => 'New description',
]);
}
public function test_update_endpoint_can_stop_running_time_entry_and_change_member_in_the_same_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$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()]), [
'member_id' => $otherMember->getKey(),
'end' => Carbon::now()->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
}
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
{ {
// Arrange // Arrange
@@ -4031,6 +4178,51 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_multiple_does_not_change_member_of_running_time_entries(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
$runningTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
$runningTimeEntryOfOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->active()->create();
$finishedTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [
$runningTimeEntry->getKey(),
$runningTimeEntryOfOtherMember->getKey(),
$finishedTimeEntry->getKey(),
],
'changes' => [
'member_id' => $otherMember->getKey(),
],
]);
// Assert
$response->assertStatus(200);
$response->assertExactJson([
'success' => [
$runningTimeEntryOfOtherMember->getKey(),
$finishedTimeEntry->getKey(),
],
'error' => [
$runningTimeEntry->getKey(),
],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $runningTimeEntry->getKey(),
'member_id' => $data->member->getKey(),
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $finishedTimeEntry->getKey(),
'member_id' => $otherMember->getKey(),
]);
}
public function test_update_multiple_updates_user_id_when_member_id_changes(): void public function test_update_multiple_updates_user_id_when_member_id_changes(): void
{ {
// Arrange // Arrange

View File

@@ -22,6 +22,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException; use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
@@ -53,6 +54,7 @@ class ApiExceptionTest extends TestCase
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException]; yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException]; yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException]; yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
yield TimeEntryMemberCanNotBeChangedWhileRunningApiException::class => [new TimeEntryMemberCanNotBeChangedWhileRunningApiException];
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException]; yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException]; yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException]; yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];

View File

@@ -140,6 +140,31 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->assertSame([], $breakEntry->tags); $this->assertSame([], $breakEntry->tags);
} }
public function test_import_skips_running_time_entries(): void
{
// Arrange
$zipPath = $this->createTestZip('solidtime_import_test_3');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(3, $report->timeEntriesCreated);
$this->assertSame(3, TimeEntry::query()->count());
$this->assertSame(0, TimeEntry::query()->whereNull('end')->count());
$this->assertNull(TimeEntry::query()->where('description', 'Still running')->first());
}
public function test_import_of_test_file_twice_succeeds(): void public function test_import_of_test_file_twice_succeeds(): void
{ {
// Arrange // Arrange

View File

@@ -228,6 +228,30 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count()); $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
} }
public function test_assign_organization_entities_to_different_member_deletes_running_time_entries_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$fromUser = User::factory()->create();
$toUser = User::factory()->create();
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
$runningTimeEntryOfFromMember = TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->active()->create();
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(2);
$runningTimeEntryOfToMember = TimeEntry::factory()->forOrganization($organization)->forMember($toUserMember)->active()->create();
// Act
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
// Assert
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $runningTimeEntryOfFromMember->getKey(),
]);
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
$this->assertSame([$runningTimeEntryOfToMember->getKey()], TimeEntry::query()->whereBelongsTo($toUser, 'user')->whereNull('end')->pluck('id')->all());
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
}
public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
{ {
// Arrange // Arrange

View File

@@ -5,10 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -137,60 +135,4 @@ class UserServiceTest extends TestCase
$this->assertSame(Role::Owner->value, $newMember->role); $this->assertSame(Role::Owner->value, $newMember->role);
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey()); $this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
} }
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
{
// Arrange — an accepted invitation exists for the email (e.g. the user
// followed the invite link, then signs up via SSO). Casing differs to
// prove the email is normalised before the invitation is matched.
$organization = Organization::factory()->create();
OrganizationInvitation::factory()
->forOrganization($organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'invitee@example.com',
]);
// Act
$user = $this->userService->createPasswordlessUser(
'Invitee',
'Invitee@Example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — invitation is materialised, no personal organization is created
$this->assertNull($user->password);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'invitee@example.com',
]);
$user->refresh();
$this->assertSame(1, $user->organizations()->count());
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
$this->assertSame(Role::Employee->value, $member->role);
}
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
{
// Act
$user = $this->userService->createPasswordlessUser(
'Solo User',
'solo@example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — a personal organization is created, owned by the user and set current
$user->refresh();
$this->assertNull($user->password);
$this->assertSame(1, $user->organizations()->count());
$organization = $user->organizations()->first();
$this->assertTrue($organization->personal_team);
$this->assertSame($user->getKey(), $organization->user_id);
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
}
} }