feat(models): Tenant, User, Role, ParentChild pivot + factories with withRole/withLicense
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
a198cd1fbe
commit
1709f0f171
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class License extends Model
|
||||
{
|
||||
use HasFactory, HasUuid, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = ['type', 'seats', 'expires_at', 'active', 'tenant_id'];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'expires_at' => 'date',
|
||||
];
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(LicenseAssignment::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LicenseAssignment extends Model
|
||||
{
|
||||
protected $fillable = ['license_id', 'user_id'];
|
||||
|
||||
public function license()
|
||||
{
|
||||
return $this->belongsTo(License::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class ParentChild extends Pivot
|
||||
{
|
||||
use HasUuid;
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $table = 'parent_child';
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Role extends Model
|
||||
{
|
||||
protected $fillable = ['name'];
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(User::class)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory, HasUuid, SoftDeletes;
|
||||
|
||||
protected $fillable = ['name', 'type', 'active'];
|
||||
|
||||
protected $casts = ['active' => 'boolean'];
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function licenses()
|
||||
{
|
||||
return $this->hasMany(License::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,31 +2,69 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, Notifiable, HasUuid, BelongsToTenant, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
protected $fillable = ['name', 'email', 'password', 'tenant_id'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token', 'id'];
|
||||
|
||||
protected $casts = ['email_verified_at' => 'datetime', 'password' => 'hashed'];
|
||||
|
||||
public function role()
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
return $this->belongsToMany(Role::class, 'role_user')->withTimestamps();
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return $this->role()->where('roles.name', $role)->exists();
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function licenseAssignments()
|
||||
{
|
||||
return $this->hasMany(LicenseAssignment::class);
|
||||
}
|
||||
|
||||
public function children()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'parent_child', 'parent_id', 'child_id')
|
||||
->using(ParentChild::class)
|
||||
->withTimestamps()
|
||||
->withoutGlobalScope(\App\Scopes\TenantScope::class);
|
||||
}
|
||||
|
||||
public function parents()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'parent_child', 'child_id', 'parent_id')
|
||||
->using(ParentChild::class)
|
||||
->withTimestamps()
|
||||
->withoutGlobalScope(\App\Scopes\TenantScope::class);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (User $user) {
|
||||
// Soft-delete cascade: DB foreign key cascades don't fire on soft-delete
|
||||
\DB::table('role_user')->where('user_id', $user->id)->delete();
|
||||
\DB::table('license_assignments')->where('user_id', $user->id)->delete();
|
||||
\DB::table('parent_child')
|
||||
->where('parent_id', $user->id)
|
||||
->orWhere('child_id', $user->id)
|
||||
->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class TenantFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company(),
|
||||
'type' => 'school',
|
||||
'active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function private(): static
|
||||
{
|
||||
return $this->state(['type' => 'private']);
|
||||
}
|
||||
|
||||
public function system(): static
|
||||
{
|
||||
return $this->state(['type' => 'system', 'name' => 'System']);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,26 +2,16 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\License;
|
||||
use App\Models\Role;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -29,17 +19,38 @@ class UserFactory extends Factory
|
|||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'remember_token' => \Illuminate\Support\Str::random(10),
|
||||
'tenant_id' => Tenant::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function withRole(string $roleName): static
|
||||
{
|
||||
return $this->afterCreating(function ($user) use ($roleName) {
|
||||
$role = Role::firstOrCreate(['name' => $roleName]);
|
||||
\DB::table('role_user')->insertOrIgnore([
|
||||
'role_id' => $role->id,
|
||||
'user_id' => $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function withLicense(License $license): static
|
||||
{
|
||||
return $this->afterCreating(function ($user) use ($license) {
|
||||
\DB::table('license_assignments')->insertOrIgnore([
|
||||
'license_id' => $license->id,
|
||||
'user_id' => $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
return $this->state(fn () => ['email_verified_at' => null]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('verhindert Zugriff auf Daten anderer Tenants', function () {
|
||||
$tenant1 = Tenant::factory()->create();
|
||||
$tenant2 = Tenant::factory()->create();
|
||||
$user1 = User::factory()->for($tenant1)->create();
|
||||
$user2 = User::factory()->for($tenant2)->create();
|
||||
|
||||
$this->actingAs($user1);
|
||||
expect(User::all()->pluck('id'))->not->toContain($user2->id);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('TenantScope gibt leeres ResultSet bei null-User zurück (kein silent-leak)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
User::factory()->for($tenant)->count(3)->create();
|
||||
|
||||
auth()->logout();
|
||||
expect(User::count())->toBe(0);
|
||||
});
|
||||
Loading…
Reference in New Issue