mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-07 21:33:18 +01:00
Compare commits
11 Commits
bb5a7fb9f9
...
fde944a84f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fde944a84f | ||
|
|
34273a4863 | ||
|
|
6fe721fc26 | ||
|
|
f921452419 | ||
|
|
30a90f80e0 | ||
|
|
135984f9ef | ||
|
|
af54b0db73 | ||
|
|
3fc2cec751 | ||
|
|
32ac8841bc | ||
|
|
1582e6f4c3 | ||
|
|
01281d80d0 |
@@ -4,15 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AuditableWithoutOwner;
|
||||
use Database\Factories\AuditFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string|null $user_type
|
||||
* @property string|null $user_id
|
||||
* @property string|null $actor_type
|
||||
* @property string|null $actor_id
|
||||
* @property string $event
|
||||
* @property string $auditable_type
|
||||
* @property string $auditable_id
|
||||
@@ -22,13 +26,71 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property string|null $tags
|
||||
* @property string|null $owner_user_id
|
||||
* @property string|null $owner_organization_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User|null $ownerUser
|
||||
* @property-read Organization|null $ownerOrganization
|
||||
*
|
||||
* @method static AuditFactory factory()
|
||||
* @method static Builder<Audit> whereMissingOwner()
|
||||
*/
|
||||
class Audit extends PackageAuditModel
|
||||
{
|
||||
/** @use HasFactory<AuditFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function ownerUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function ownerOrganization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'owner_organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
|
||||
*/
|
||||
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
|
||||
{
|
||||
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
|
||||
|
||||
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function getAuditableTypesWithoutOwner(): array
|
||||
{
|
||||
return collect(Relation::morphMap())
|
||||
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
|
||||
* These are audits whose owner no longer exists or could not be determined (yet).
|
||||
*
|
||||
* @param Builder<Audit> $builder
|
||||
*/
|
||||
public function scopeWhereMissingOwner(Builder $builder): void
|
||||
{
|
||||
$builder->whereNull('owner_organization_id')
|
||||
->whereNull('owner_user_id')
|
||||
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Models/Concerns/AuditableThroughParent.php
Normal file
22
app/Models/Concerns/AuditableThroughParent.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Marks an auditable model whose audits are owned by the owner of its parent model,
|
||||
* because the model itself has no organization_id (for example a project member belongs to an organization via its project).
|
||||
* The parent model has to have an organization_id column.
|
||||
* This is used to set the owner of new audits (CustomAuditable) and to backfill the owner of existing audits.
|
||||
*/
|
||||
interface AuditableThroughParent
|
||||
{
|
||||
/**
|
||||
* @return BelongsTo<covariant Model, covariant Model>
|
||||
*/
|
||||
public function getAuditParentRelation(): BelongsTo;
|
||||
}
|
||||
14
app/Models/Concerns/AuditableWithoutOwner.php
Normal file
14
app/Models/Concerns/AuditableWithoutOwner.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
/**
|
||||
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
|
||||
* either because the model belongs to neither of them, or because the model and its audits have to be kept
|
||||
* when its organization or user is deleted (for example billing records).
|
||||
* Audits of these models are not deleted together with an organization or user,
|
||||
* and are not considered as missing an owner (for example by the audit backfill command).
|
||||
*/
|
||||
interface AuditableWithoutOwner {}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
trait CustomAuditable
|
||||
@@ -19,4 +21,92 @@ trait CustomAuditable
|
||||
{
|
||||
$this->auditEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization that owns the audited model.
|
||||
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
|
||||
*/
|
||||
public function getAuditOwnerOrganizationId(): ?string
|
||||
{
|
||||
if ($this instanceof AuditableThroughParent) {
|
||||
$relation = $this->getAuditParentRelation();
|
||||
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
|
||||
if ($parentId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
|
||||
$relationName = $relation->getRelationName();
|
||||
if ($this->relationLoaded($relationName)) {
|
||||
$parent = $this->getRelation($relationName);
|
||||
if ($parent instanceof Model
|
||||
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
|
||||
&& array_key_exists('organization_id', $parent->getAttributes())) {
|
||||
/** @var string|null $organizationId */
|
||||
$organizationId = $parent->getAttributes()['organization_id'];
|
||||
|
||||
return $organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var string|null $organizationId */
|
||||
$organizationId = $relation->getRelated()->newQuery()
|
||||
->toBase()
|
||||
->where($relation->getOwnerKeyName(), $parentId)
|
||||
->value('organization_id');
|
||||
|
||||
return $organizationId;
|
||||
}
|
||||
|
||||
return $this->getAttributes()['organization_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user that owns the audited model.
|
||||
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
|
||||
*/
|
||||
public function getAuditOwnerUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Models that are the owner of their own audits can not record the deletion audit,
|
||||
* since the audit would reference the already deleted model and therefore violate the foreign key.
|
||||
*/
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, string>
|
||||
*/
|
||||
public function getAuditEvents(): array
|
||||
{
|
||||
$events = $this->auditEvents ?? Config::get('audit.events', [
|
||||
'created',
|
||||
'updated',
|
||||
'deleted',
|
||||
'restored',
|
||||
]);
|
||||
|
||||
if ($this->isAuditOwnerOfItself()) {
|
||||
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function transformAudit(array $data): array
|
||||
{
|
||||
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
|
||||
$data['owner_user_id'] = $this->getAuditOwnerUserId();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,16 @@ class Organization extends Model implements AuditableContract
|
||||
protected $attributes = [
|
||||
];
|
||||
|
||||
public function getAuditOwnerOrganizationId(): ?string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the users that belong to the team.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AuditableThroughParent;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
@@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||
* @method static ProjectMemberFactory factory()
|
||||
*/
|
||||
class ProjectMember extends Model implements AuditableContract
|
||||
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
@@ -82,4 +83,12 @@ class ProjectMember extends Model implements AuditableContract
|
||||
$query->whereBelongsTo($organization, 'organization');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function getAuditParentRelation(): BelongsTo
|
||||
{
|
||||
return $this->project();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,16 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
'send_time_entry_still_running_email' => true,
|
||||
];
|
||||
|
||||
public function getAuditOwnerUserId(): ?string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to the user's profile photo.
|
||||
*
|
||||
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'user',
|
||||
'morph_prefix' => 'actor',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
|
||||
@@ -35,7 +35,7 @@ return [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
@@ -47,7 +47,7 @@ return [
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
@@ -57,12 +57,12 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSL_MODE', 'prefer'),
|
||||
],
|
||||
|
||||
'pgsql_test' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'host' => env('DB_TEST_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_TEST_PORT', '5432'),
|
||||
'database' => env('DB_TEST_DATABASE', 'forge'),
|
||||
@@ -72,12 +72,12 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSL_MODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
|
||||
@@ -91,7 +91,7 @@ class UserFactory extends Factory
|
||||
|
||||
public function withProfilePicture(): static
|
||||
{
|
||||
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||
$profilePhoto = $this->generateProfilePhoto();
|
||||
/** @see FileHelpers::hashName */
|
||||
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
|
||||
@@ -103,6 +103,21 @@ class UserFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PNG image with a random background color.
|
||||
* Note: The image is generated locally, since downloading a placeholder image (Faker image()) depends on an external service.
|
||||
*/
|
||||
private function generateProfilePhoto(): string
|
||||
{
|
||||
$image = imagecreatetruecolor(500, 500);
|
||||
$color = imagecolorallocate($image, $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255));
|
||||
imagefill($image, 0, 0, $color);
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user should have a personal team.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,8 @@ class CreateAuditsTable extends Migration
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table): void {
|
||||
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
// Note: The morph prefix is hardcoded, since the columns are renamed in a later migration
|
||||
$morphPrefix = 'user';
|
||||
|
||||
$table->bigIncrements('id');
|
||||
$table->string($morphPrefix.'_type')->nullable();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Note: Renaming columns and adding nullable columns without default are metadata-only operations in PostgreSQL,
|
||||
* so this migration is fast even for a large audits table.
|
||||
* The indexes and foreign keys for the new columns are added in a separate non-transactional migration.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('audits', function (Blueprint $table): void {
|
||||
$table->renameColumn('user_type', 'actor_type');
|
||||
$table->renameColumn('user_id', 'actor_id');
|
||||
$table->renameIndex('audits_user_id_user_type_index', 'audits_actor_id_actor_type_index');
|
||||
$table->uuid('owner_user_id')->nullable();
|
||||
$table->uuid('owner_organization_id')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('audits', function (Blueprint $table): void {
|
||||
$table->dropColumn('owner_user_id');
|
||||
$table->dropColumn('owner_organization_id');
|
||||
$table->renameIndex('audits_actor_id_actor_type_index', 'audits_user_id_user_type_index');
|
||||
$table->renameColumn('actor_type', 'user_type');
|
||||
$table->renameColumn('actor_id', 'user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* PostgreSQL cannot build an index concurrently inside a transaction.
|
||||
* Keeping this migration non-transactional prevents long write locks on the (large) audits table in production.
|
||||
* Every step is idempotent, so the migration can be re-run if it fails halfway.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $withinTransaction = false;
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$this->createIndex('audits_owner_user_id_index', 'owner_user_id');
|
||||
$this->createIndex('audits_owner_organization_id_index', 'owner_organization_id');
|
||||
|
||||
$this->createForeignKey('audits_owner_user_id_foreign', 'owner_user_id', 'users');
|
||||
$this->createForeignKey('audits_owner_organization_id_foreign', 'owner_organization_id', 'organizations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_user_id_foreign');
|
||||
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_organization_id_foreign');
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_user_id_index');
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_organization_id_index');
|
||||
}
|
||||
|
||||
private function createIndex(string $index, string $column): void
|
||||
{
|
||||
$state = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT pg_index.indisvalid::int AS valid
|
||||
FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_class.relname = ?
|
||||
SQL,
|
||||
[$index],
|
||||
);
|
||||
|
||||
if ($state !== null && (bool) $state->valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A failed concurrent index build leaves an invalid index behind, that needs to be dropped before rebuilding
|
||||
if ($state !== null) {
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' '.$index);
|
||||
}
|
||||
|
||||
DB::statement('CREATE INDEX'.$this->concurrently().' '.$index.' ON audits ('.$column.')');
|
||||
}
|
||||
|
||||
private function createForeignKey(string $constraint, string $column, string $referencedTable): void
|
||||
{
|
||||
$exists = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_constraint.connamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_constraint.conname = ?
|
||||
SQL,
|
||||
[$constraint],
|
||||
) !== null;
|
||||
|
||||
// Note: Adding the constraint as NOT VALID only needs a short lock, the validation of the existing rows
|
||||
// afterward does not block reads or writes on the audits table.
|
||||
if (! $exists) {
|
||||
DB::statement('ALTER TABLE audits ADD CONSTRAINT '.$constraint.' FOREIGN KEY ('.$column.') REFERENCES '.$referencedTable.' (id) ON DELETE CASCADE NOT VALID');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE audits VALIDATE CONSTRAINT '.$constraint);
|
||||
}
|
||||
|
||||
private function concurrently(): string
|
||||
{
|
||||
return DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
|
||||
}
|
||||
};
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.8"
|
||||
"ref": "v0.0.9"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "v0.0.3"
|
||||
"ref": "v0.0.4"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.7"
|
||||
"ref": "v0.0.8"
|
||||
},
|
||||
"Auditing": {
|
||||
"repository": "solidtime-io/extension-auditing",
|
||||
"ref": "v0.0.2"
|
||||
"ref": "v0.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ class TrustHostsTest extends TestCase
|
||||
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
|
||||
|
||||
try {
|
||||
dump($request->getHost());
|
||||
// Note: Throws an exception if the host is not trusted
|
||||
$request->getHost();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
|
||||
209
tests/Unit/Model/AuditModelTest.php
Normal file
209
tests/Unit/Model/AuditModelTest.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Model;
|
||||
|
||||
use App\Models\Audit;
|
||||
use App\Models\Concerns\AuditableWithoutOwner;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
|
||||
#[CoversClass(Audit::class)]
|
||||
class AuditModelTest extends ModelTestAbstract
|
||||
{
|
||||
public function test_it_belongs_to_an_owner_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$audit = Audit::factory()->create([
|
||||
'owner_organization_id' => $organization->getKey(),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$audit->refresh();
|
||||
$ownerOrganizationRel = $audit->ownerOrganization;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($ownerOrganizationRel);
|
||||
$this->assertTrue($ownerOrganizationRel->is($organization));
|
||||
}
|
||||
|
||||
public function test_it_belongs_to_an_owner_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$audit = Audit::factory()->create([
|
||||
'owner_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$audit->refresh();
|
||||
$ownerUserRel = $audit->ownerUser;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($ownerUserRel);
|
||||
$this->assertTrue($ownerUserRel->is($user));
|
||||
}
|
||||
|
||||
public function test_audits_of_models_with_organization_have_the_organization_as_owner(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$member = Member::factory()->forOrganization($organization)->create();
|
||||
|
||||
// Act
|
||||
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create();
|
||||
|
||||
// Assert
|
||||
$audit = Audit::query()->where('auditable_id', $timeEntry->getKey())->sole();
|
||||
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
|
||||
$this->assertNull($audit->owner_user_id);
|
||||
}
|
||||
|
||||
public function test_audits_of_project_members_have_the_organization_of_the_project_as_owner(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create();
|
||||
$member = Member::factory()->forOrganization($organization)->create();
|
||||
|
||||
// Act
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();
|
||||
|
||||
// Assert
|
||||
$audit = Audit::query()->where('auditable_id', $projectMember->getKey())->sole();
|
||||
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
|
||||
$this->assertNull($audit->owner_user_id);
|
||||
}
|
||||
|
||||
public function test_owner_organization_through_parent_uses_the_loaded_parent_without_query(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create();
|
||||
$projectMember->load('project');
|
||||
DB::enableQueryLog();
|
||||
|
||||
// Act
|
||||
$ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId();
|
||||
|
||||
// Assert
|
||||
$this->assertSame($organization->getKey(), $ownerOrganizationId);
|
||||
$this->assertCount(0, DB::getQueryLog());
|
||||
}
|
||||
|
||||
public function test_owner_organization_through_parent_ignores_a_loaded_parent_that_does_not_match_the_foreign_key(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$project = Project::factory()->forOrganization($organization)->create();
|
||||
$otherProject = Project::factory()->forOrganization($otherOrganization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create();
|
||||
$projectMember->load('project');
|
||||
$projectMember->project_id = $otherProject->getKey();
|
||||
|
||||
// Act
|
||||
$ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId();
|
||||
|
||||
// Assert
|
||||
$this->assertSame($otherOrganization->getKey(), $ownerOrganizationId);
|
||||
}
|
||||
|
||||
public function test_audits_of_an_organization_have_the_organization_itself_as_owner(): void
|
||||
{
|
||||
// Act
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
// Assert
|
||||
$audit = Audit::query()->where('auditable_id', $organization->getKey())->sole();
|
||||
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
|
||||
$this->assertNull($audit->owner_user_id);
|
||||
}
|
||||
|
||||
public function test_audits_of_a_user_have_the_user_itself_as_owner(): void
|
||||
{
|
||||
// Act
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Assert
|
||||
$audit = Audit::query()->where('auditable_id', $user->getKey())->sole();
|
||||
$this->assertSame($user->getKey(), $audit->owner_user_id);
|
||||
$this->assertNull($audit->owner_organization_id);
|
||||
}
|
||||
|
||||
public function test_deleting_an_owner_deletes_its_audits_and_does_not_create_a_deletion_audit(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
// Act
|
||||
$user->delete();
|
||||
$organization->delete();
|
||||
|
||||
// Assert
|
||||
$this->assertSame(0, Audit::query()->where('auditable_id', $user->getKey())->count());
|
||||
$this->assertSame(0, Audit::query()->where('auditable_id', $organization->getKey())->count());
|
||||
$this->assertSame(1, Audit::query()->where('auditable_id', $otherUser->getKey())->count());
|
||||
}
|
||||
|
||||
public function test_auditable_types_without_owner_are_determined_by_the_marker_interface(): void
|
||||
{
|
||||
// Arrange
|
||||
$originalMorphMap = Relation::morphMap();
|
||||
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
|
||||
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
|
||||
|
||||
try {
|
||||
// Act
|
||||
$typesWithoutOwner = Audit::getAuditableTypesWithoutOwner();
|
||||
$isWithoutOwner = Audit::isAuditableTypeWithoutOwner('model-without-owner');
|
||||
$isTimeEntryWithoutOwner = Audit::isAuditableTypeWithoutOwner((new TimeEntry)->getMorphClass());
|
||||
|
||||
// Assert
|
||||
$this->assertContains('model-without-owner', $typesWithoutOwner);
|
||||
$this->assertNotContains((new TimeEntry)->getMorphClass(), $typesWithoutOwner);
|
||||
$this->assertTrue($isWithoutOwner);
|
||||
$this->assertFalse($isTimeEntryWithoutOwner);
|
||||
} finally {
|
||||
Relation::morphMap($originalMorphMap, false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_scope_where_missing_owner_only_returns_audits_without_owner_whose_type_should_have_one(): void
|
||||
{
|
||||
// Arrange
|
||||
$originalMorphMap = Relation::morphMap();
|
||||
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
|
||||
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
Audit::query()->delete();
|
||||
$missingOwnerAudit = Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass()]);
|
||||
Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass(), 'owner_organization_id' => $organization->getKey()]);
|
||||
Audit::factory()->create(['owner_user_id' => $user->getKey()]);
|
||||
Audit::factory()->create(['auditable_type' => 'model-without-owner']);
|
||||
|
||||
try {
|
||||
// Act
|
||||
$auditIds = Audit::query()->whereMissingOwner()->pluck('id')->all();
|
||||
|
||||
// Assert
|
||||
$this->assertSame([$missingOwnerAudit->getKey()], $auditIds);
|
||||
} finally {
|
||||
Relation::morphMap($originalMorphMap, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Tests\Unit\Service;
|
||||
use App\Enums\Role;
|
||||
use App\Events\BeforeOrganizationDeletion;
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Models\Audit;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
@@ -182,6 +183,8 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
||||
// Assert
|
||||
$this->assertOrganizationDeleted($organization->organization);
|
||||
$this->assertOrganizationNothingDeleted($otherOrganization->organization);
|
||||
$this->assertSame(0, Audit::query()->where('owner_organization_id', $organization->organization->getKey())->count());
|
||||
$this->assertGreaterThan(0, Audit::query()->where('owner_organization_id', $otherOrganization->organization->getKey())->count());
|
||||
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||
&& $log->message === 'Start deleting organization'
|
||||
&& $log->context['organization_id'] === $organization->organization->getKey(),
|
||||
@@ -318,6 +321,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
$this->assertSame(0, Audit::query()->where('owner_user_id', $user->getKey())->count());
|
||||
$this->assertSame(0, Audit::query()->where('owner_organization_id', $user->current_team_id)->count());
|
||||
$this->assertGreaterThan(0, Audit::query()->where('owner_user_id', $otherUser->getKey())->count());
|
||||
$this->assertGreaterThan(0, Audit::query()->where('owner_organization_id', $otherUser->current_team_id)->count());
|
||||
Storage::disk(config('filesystems.public'))->assertMissing($user->profile_photo_path);
|
||||
Storage::disk(config('filesystems.public'))->assertExists($otherUser->profile_photo_path);
|
||||
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
|
||||
@@ -332,6 +339,24 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_delete_user_keeps_audits_of_other_organizations_where_the_user_is_the_actor(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$audit = Audit::factory()->auditUser($user)->auditFor($otherOrganization)->create([
|
||||
'owner_organization_id' => $otherOrganization->getKey(),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$this->deletionService->deleteUser($user);
|
||||
|
||||
// Assert
|
||||
$audit->refresh();
|
||||
$this->assertSame($user->getKey(), $audit->actor_id);
|
||||
$this->assertSame($otherOrganization->getKey(), $audit->owner_organization_id);
|
||||
}
|
||||
|
||||
public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user