feat: add routes, lang, tests, stubs, docs, and docker configurations
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
|
||||
function grantManageAccessRightsForPerms(User $user): void
|
||||
{
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage access rights', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'view access rights', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
test('guest cannot access permissions index', function () {
|
||||
$this->get('/permissions')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission gets 403', function () {
|
||||
$u = User::factory()->create();
|
||||
$this->actingAs($u)->get('/permissions')->assertForbidden();
|
||||
});
|
||||
|
||||
test('store creates a permission with web guard', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson('/permissions', [
|
||||
'name' => 'view.reports',
|
||||
'guard_name' => 'web',
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJson(['success' => true]);
|
||||
$this->assertDatabaseHas('permissions', [
|
||||
'name' => 'view.reports',
|
||||
'guard_name' => 'web',
|
||||
]);
|
||||
});
|
||||
|
||||
test('same name allowed across different guards', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
Permission::create(['name' => 'shared.perm', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)->postJson('/permissions', [
|
||||
'name' => 'shared.perm',
|
||||
'guard_name' => 'api',
|
||||
])->assertOk();
|
||||
});
|
||||
|
||||
test('store rejects duplicate name within same guard', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
Permission::create(['name' => 'duplicate.perm', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)->postJson('/permissions', [
|
||||
'name' => 'duplicate.perm',
|
||||
'guard_name' => 'web',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store rejects invalid guard', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
|
||||
$this->actingAs($admin)->postJson('/permissions', [
|
||||
'name' => 'some.perm',
|
||||
'guard_name' => 'console',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store rejects illegal characters in name', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
|
||||
$this->actingAs($admin)->postJson('/permissions', [
|
||||
'name' => 'bad name with space!',
|
||||
'guard_name' => 'web',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('update can rename a permission', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
$p = Permission::create(['name' => 'old.name', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)->putJson("/permissions/{$p->id}", [
|
||||
'name' => 'new.name',
|
||||
'guard_name' => 'web',
|
||||
])->assertOk();
|
||||
|
||||
expect($p->fresh()->name)->toBe('new.name');
|
||||
});
|
||||
|
||||
test('toggleStatus flips is_active', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
$p = Permission::create(['name' => 'flip.able', 'guard_name' => 'web', 'is_active' => 1]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/permissions/toggle-status', ['id' => $p->id, 'status' => 'deactivate'])
|
||||
->assertOk();
|
||||
expect((bool) $p->fresh()->is_active)->toBeFalse();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/permissions/toggle-status', ['id' => $p->id, 'status' => 'activate'])
|
||||
->assertOk();
|
||||
expect((bool) $p->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('destroy soft deletes permission', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRightsForPerms($admin);
|
||||
$p = Permission::create(['name' => 'to.delete', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)->deleteJson("/permissions/{$p->id}")->assertOk();
|
||||
expect(Permission::withTrashed()->find($p->id)->trashed())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\WithFaker;
|
||||
|
||||
uses(WithFaker::class);
|
||||
|
||||
function grantManageAccessRights(User $user): void
|
||||
{
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage access rights', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'view access rights', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
test('guest cannot access roles index', function () {
|
||||
$this->get('/roles')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('authenticated user without permission gets 403 on roles index', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get('/roles')->assertForbidden();
|
||||
});
|
||||
|
||||
test('user with manage access rights can store a new role', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$p = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/roles', [
|
||||
'name' => 'editor-role',
|
||||
'guard_name' => 'web',
|
||||
'permissions' => [$p->id],
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJson(['success' => true]);
|
||||
$this->assertDatabaseHas('roles', ['name' => 'editor-role']);
|
||||
|
||||
$role = Role::where('name', 'editor-role')->first();
|
||||
expect($role->hasPermissionTo('view dashboard'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('store rejects duplicate role name', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$p = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']);
|
||||
Role::create(['name' => 'dup-role', 'guard_name' => 'web']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/roles', [
|
||||
'name' => 'dup-role',
|
||||
'guard_name' => 'web',
|
||||
'permissions' => [$p->id],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store rejects empty permissions array', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/roles', [
|
||||
'name' => 'no-perm-role',
|
||||
'guard_name' => 'web',
|
||||
'permissions' => [],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store rejects invalid characters in role name', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$p = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/roles', [
|
||||
'name' => 'role with space!',
|
||||
'guard_name' => 'web',
|
||||
'permissions' => [$p->id],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('update replaces role permissions', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$p1 = Permission::firstOrCreate(['name' => 'view dashboard', 'guard_name' => 'web']);
|
||||
$p2 = Permission::firstOrCreate(['name' => 'view user directory', 'guard_name' => 'web']);
|
||||
$role = Role::create(['name' => 'original-role', 'guard_name' => 'web']);
|
||||
$role->givePermissionTo('view dashboard');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->putJson("/roles/{$role->id}", [
|
||||
'name' => 'renamed-role',
|
||||
'guard_name' => 'web',
|
||||
'permissions' => [$p2->id],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$role->refresh();
|
||||
expect($role->name)->toBe('renamed-role');
|
||||
expect($role->hasPermissionTo('view user directory'))->toBeTrue();
|
||||
expect($role->hasPermissionTo('view dashboard'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('toggleStatus activates and deactivates role', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$role = Role::create(['name' => 'toggleable', 'guard_name' => 'web', 'is_active' => true]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/roles/toggle-status', ['id' => $role->id, 'status' => 'deactivate'])
|
||||
->assertOk();
|
||||
expect((bool) $role->fresh()->is_active)->toBeFalse();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/roles/toggle-status', ['id' => $role->id, 'status' => 'activate'])
|
||||
->assertOk();
|
||||
expect((bool) $role->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('destroy soft deletes role when no users assigned', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$role = Role::create(['name' => 'doomed', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson("/roles/{$role->id}")
|
||||
->assertOk();
|
||||
|
||||
expect(Role::withTrashed()->find($role->id)->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('destroy blocks deletion when role is still assigned to a user', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRights($admin);
|
||||
$role = Role::create(['name' => 'in-use', 'guard_name' => 'web']);
|
||||
$victim = User::factory()->create();
|
||||
$victim->assignRole($role);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->deleteJson("/roles/{$role->id}")
|
||||
->assertStatus(422)
|
||||
->assertJson(['success' => false]);
|
||||
|
||||
expect(Role::find($role->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('restore brings back a soft-deleted role', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$role = Role::create(['name' => 'restorable', 'guard_name' => 'web']);
|
||||
$role->delete();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson("/roles/{$role->id}/restore")
|
||||
->assertOk();
|
||||
|
||||
expect(Role::find($role->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('forceDelete permanently removes role', function () {
|
||||
$user = User::factory()->create();
|
||||
grantManageAccessRights($user);
|
||||
$role = Role::create(['name' => 'gone-forever', 'guard_name' => 'web']);
|
||||
$role->delete();
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson("/roles/{$role->id}/force")
|
||||
->assertOk();
|
||||
|
||||
expect(Role::withTrashed()->find($role->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('forceDelete blocks when role has users assigned', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageAccessRights($admin);
|
||||
$role = Role::create(['name' => 'sticky', 'guard_name' => 'web']);
|
||||
$victim = User::factory()->create();
|
||||
$victim->assignRole($role);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->deleteJson("/roles/{$role->id}/force")
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Role::find($role->id))->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
|
||||
function grantManageUserDirectory(User $user): void
|
||||
{
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage user directory', 'guard_name' => 'web']);
|
||||
Permission::firstOrCreate(['name' => 'view user directory', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
function defaultRole(): Role
|
||||
{
|
||||
return Role::firstOrCreate(['name' => 'member', 'guard_name' => 'web']);
|
||||
}
|
||||
|
||||
const STRONG_PASSWORD = 'Str0ng!Passw0rd2026';
|
||||
|
||||
test('guest cannot access user index', function () {
|
||||
$this->get('/users')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission gets 403', function () {
|
||||
$u = User::factory()->create();
|
||||
$this->actingAs($u)->get('/users')->assertForbidden();
|
||||
});
|
||||
|
||||
test('store creates user, hashes password, assigns roles', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$role = defaultRole();
|
||||
|
||||
$response = $this->actingAs($admin)->postJson('/users', [
|
||||
'name' => 'Jane Doe',
|
||||
'email' => 'jane@example.com',
|
||||
'password' => STRONG_PASSWORD,
|
||||
'roles' => [$role->id],
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJson(['success' => true]);
|
||||
|
||||
$created = User::where('email', 'jane@example.com')->first();
|
||||
expect($created)->not->toBeNull();
|
||||
expect($created->password)->not->toBe(STRONG_PASSWORD);
|
||||
expect($created->hasRole('member'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('store rejects weak password', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$role = defaultRole();
|
||||
|
||||
$this->actingAs($admin)->postJson('/users', [
|
||||
'name' => 'Weak Pass',
|
||||
'email' => 'weak@example.com',
|
||||
'password' => 'short',
|
||||
'roles' => [$role->id],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store rejects duplicate email', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$role = defaultRole();
|
||||
User::factory()->create(['email' => 'taken@example.com']);
|
||||
|
||||
$this->actingAs($admin)->postJson('/users', [
|
||||
'name' => 'Dup Email',
|
||||
'email' => 'taken@example.com',
|
||||
'password' => STRONG_PASSWORD,
|
||||
'roles' => [$role->id],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store rejects name with digits', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$role = defaultRole();
|
||||
|
||||
$this->actingAs($admin)->postJson('/users', [
|
||||
'name' => 'John123',
|
||||
'email' => 'john@example.com',
|
||||
'password' => STRONG_PASSWORD,
|
||||
'roles' => [$role->id],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store requires at least one role', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
|
||||
$this->actingAs($admin)->postJson('/users', [
|
||||
'name' => 'No Role',
|
||||
'email' => 'norole@example.com',
|
||||
'password' => STRONG_PASSWORD,
|
||||
'roles' => [],
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('update can change name and reset roles without touching password', function () {
|
||||
$admin = User::factory()->create(['name' => 'Admin User']);
|
||||
grantManageUserDirectory($admin);
|
||||
$r1 = defaultRole();
|
||||
$r2 = Role::firstOrCreate(['name' => 'editor', 'guard_name' => 'web']);
|
||||
$target = User::factory()->create(['name' => 'Old Name']);
|
||||
$target->assignRole($r1);
|
||||
$originalHash = $target->password;
|
||||
|
||||
$this->actingAs($admin)->putJson("/users/{$target->id}", [
|
||||
'name' => 'Renamed User',
|
||||
'email' => $target->email,
|
||||
'roles' => [$r2->id],
|
||||
])->assertOk();
|
||||
|
||||
$target->refresh();
|
||||
expect($target->name)->toBe('Renamed User');
|
||||
expect($target->password)->toBe($originalHash);
|
||||
expect($target->hasRole('editor'))->toBeTrue();
|
||||
expect($target->hasRole('member'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('update changes password when provided', function () {
|
||||
$admin = User::factory()->create(['name' => 'Admin User']);
|
||||
grantManageUserDirectory($admin);
|
||||
$role = defaultRole();
|
||||
$target = User::factory()->create(['name' => 'Target User']);
|
||||
$target->assignRole($role);
|
||||
$original = $target->password;
|
||||
|
||||
$this->actingAs($admin)->putJson("/users/{$target->id}", [
|
||||
'name' => 'Target User',
|
||||
'email' => $target->email,
|
||||
'password' => STRONG_PASSWORD,
|
||||
'roles' => [$role->id],
|
||||
])->assertOk();
|
||||
|
||||
expect($target->fresh()->password)->not->toBe($original);
|
||||
});
|
||||
|
||||
test('toggleStatus deactivates and activates user', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$target = User::factory()->create(['is_active' => 1]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/users/toggle-status', ['id' => $target->id, 'status' => 'deactivate'])
|
||||
->assertOk();
|
||||
expect((bool) $target->fresh()->is_active)->toBeFalse();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/users/toggle-status', ['id' => $target->id, 'status' => 'activate'])
|
||||
->assertOk();
|
||||
expect((bool) $target->fresh()->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('destroy soft deletes user', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$target = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->deleteJson("/users/{$target->id}")->assertOk();
|
||||
expect(User::withTrashed()->find($target->id)->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('restore brings soft-deleted user back', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$target = User::factory()->create();
|
||||
$target->delete();
|
||||
|
||||
$this->actingAs($admin)->postJson("/users/{$target->id}/restore")->assertOk();
|
||||
expect(User::find($target->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('forceDelete blocks self-deletion', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$admin->delete();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->deleteJson("/users/{$admin->id}/force")
|
||||
->assertStatus(403)
|
||||
->assertJson(['success' => false]);
|
||||
|
||||
expect(User::withTrashed()->find($admin->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('forceDelete permanently removes another user', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantManageUserDirectory($admin);
|
||||
$target = User::factory()->create();
|
||||
$target->delete();
|
||||
|
||||
$this->actingAs($admin)->deleteJson("/users/{$target->id}/force")->assertOk();
|
||||
expect(User::withTrashed()->find($target->id))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
|
||||
Role::findOrCreate('User', 'web');
|
||||
});
|
||||
|
||||
// ── deleteAccount ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('delete account succeeds when correct password is supplied', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('secret')]);
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson('/api/v1/profile/delete', ['password' => 'secret'])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
expect(User::find($user->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('delete account is rejected with wrong password', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('secret')]);
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson('/api/v1/profile/delete', ['password' => 'wrong'])
|
||||
->assertStatus(422);
|
||||
|
||||
expect(User::find($user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('delete account requires password field', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson('/api/v1/profile/delete', [])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('delete account is not accessible to guests', function () {
|
||||
$this->deleteJson('/api/v1/profile/delete', ['password' => 'x'])
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('delete account also revokes all user tokens', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('secret')]);
|
||||
$user->createToken('device-a');
|
||||
$user->createToken('device-b');
|
||||
$token = $user->createToken('device-c')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson('/api/v1/profile/delete', ['password' => 'secret'])
|
||||
->assertOk();
|
||||
|
||||
expect($user->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ── updatePassword (API) ──────────────────────────────────────────────────────
|
||||
|
||||
test('api password update succeeds with valid credentials', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('old-pass')]);
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/profile/password', [
|
||||
'current_password' => 'old-pass',
|
||||
'password' => 'New-Api-Pass1',
|
||||
'password_confirmation' => 'New-Api-Pass1',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
expect(Hash::check('New-Api-Pass1', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('api password update is rejected when current password is wrong', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('real-pass')]);
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/profile/password', [
|
||||
'current_password' => 'not-real',
|
||||
'password' => 'New-Api-Pass2',
|
||||
'password_confirmation' => 'New-Api-Pass2',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('api password update is rejected when reusing a history password', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 3]);
|
||||
|
||||
$user = User::factory()->create(['password' => Hash::make('current')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('Recycled-Api!1')]);
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/profile/password', [
|
||||
'current_password' => 'current',
|
||||
'password' => 'Recycled-Api!1',
|
||||
'password_confirmation' => 'Recycled-Api!1',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('status', 'error');
|
||||
});
|
||||
|
||||
test('api password update records change in password history', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 5]);
|
||||
|
||||
$user = User::factory()->create(['password' => Hash::make('old')]);
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/profile/password', [
|
||||
'current_password' => 'old',
|
||||
'password' => 'Api-NewPass-99',
|
||||
'password_confirmation' => 'Api-NewPass-99',
|
||||
]);
|
||||
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ── register with PasswordPolicyService ──────────────────────────────────────
|
||||
|
||||
test('register enforces min length from password policy setting', function () {
|
||||
app(SystemConfigService::class)->update(['password_min_length' => 12]);
|
||||
|
||||
$this->postJson('/api/v1/register', [
|
||||
'name' => 'Test',
|
||||
'email' => 'policy@example.com',
|
||||
'password' => 'short',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('register succeeds when password meets policy min length', function () {
|
||||
app(SystemConfigService::class)->update(['password_min_length' => 6]);
|
||||
|
||||
$this->postJson('/api/v1/register', [
|
||||
'name' => 'Policy User',
|
||||
'email' => 'policy2@example.com',
|
||||
'password' => 'longenough',
|
||||
])->assertStatus(201);
|
||||
});
|
||||
|
||||
test('register enforces numeric requirement when setting is enabled', function () {
|
||||
app(SystemConfigService::class)->update([
|
||||
'password_require_numeric' => true,
|
||||
'password_min_length' => 6,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/register', [
|
||||
'name' => 'No Digits',
|
||||
'email' => 'nodigits@example.com',
|
||||
'password' => 'NoDigitsHere',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── updateProfile ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('authenticated user can update their name via api', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/profile/update', [
|
||||
'name' => 'Updated Name',
|
||||
'email' => $user->email,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
expect($user->fresh()->name)->toBe('Updated Name');
|
||||
});
|
||||
|
||||
test('profile update rejects name longer than 255 chars', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('app')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/profile/update', [
|
||||
'name' => str_repeat('a', 256),
|
||||
'email' => $user->email,
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
beforeEach(function () {
|
||||
// Ensure the User role exists so register() can assign it
|
||||
Role::findOrCreate('User', 'web');
|
||||
});
|
||||
|
||||
// ── Register ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('user can register', function () {
|
||||
$response = $this->postJson('/api/v1/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'newuser@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonStructure(['data' => ['user', 'token']]);
|
||||
});
|
||||
|
||||
test('register rejects duplicate email', function () {
|
||||
User::factory()->create(['email' => 'existing@example.com']);
|
||||
|
||||
$this->postJson('/api/v1/register', [
|
||||
'name' => 'Another',
|
||||
'email' => 'existing@example.com',
|
||||
'password' => 'password123',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── Login ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('user can login with valid credentials', function () {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('secret123'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
$user->assignRole('User');
|
||||
|
||||
$this->postJson('/api/v1/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
])->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonStructure(['data' => ['token']]);
|
||||
});
|
||||
|
||||
test('login rejects wrong password', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('correct')]);
|
||||
|
||||
$this->postJson('/api/v1/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong',
|
||||
])->assertUnauthorized()
|
||||
->assertJsonPath('status', 'error');
|
||||
});
|
||||
|
||||
test('login rejects inactive user', function () {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertForbidden();
|
||||
});
|
||||
|
||||
// ── Logout ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test('authenticated user can logout', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('test')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/v1/logout')
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
});
|
||||
|
||||
test('unauthenticated request to logout returns 401', function () {
|
||||
$this->postJson('/api/v1/logout')->assertUnauthorized();
|
||||
});
|
||||
|
||||
// ── Get User ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('authenticated user can fetch own profile', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/v1/user')
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('data.user.email', $user->email);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Models\DeviceToken;
|
||||
use App\Models\User;
|
||||
|
||||
test('guest cannot register device token', function () {
|
||||
$this->postJson('/api/v1/devices/register', [
|
||||
'token' => 'some-fcm-token',
|
||||
'platform' => 'android',
|
||||
])->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('authenticated user can register device token', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/v1/devices/register', [
|
||||
'token' => 'fcm-token-'.uniqid(),
|
||||
'platform' => 'android',
|
||||
'device_name' => 'Samsung Galaxy',
|
||||
'app_version' => '1.2.3',
|
||||
])->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonStructure(['data' => ['device_id']]);
|
||||
});
|
||||
|
||||
test('duplicate token upserts without error', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = 'fcm-stable-token';
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/v1/devices/register', ['token' => $token, 'platform' => 'ios'])
|
||||
->assertOk();
|
||||
|
||||
// Second call with same token — should update, not duplicate
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/v1/devices/register', ['token' => $token, 'platform' => 'ios'])
|
||||
->assertOk();
|
||||
|
||||
expect(DeviceToken::where('token', $token)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('authenticated user can unregister device token', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = 'fcm-token-to-remove';
|
||||
|
||||
DeviceToken::create([
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
'platform' => 'android',
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->deleteJson('/api/v1/devices/unregister', ['token' => $token])
|
||||
->assertOk();
|
||||
|
||||
expect(DeviceToken::where('token', $token)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
test('health endpoint returns 200 when no check fails', function () {
|
||||
$response = $this->getJson('/api/health');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['status', 'timestamp', 'checks' => ['database', 'storage', 'queue']]);
|
||||
|
||||
expect($response->json('status'))->toBeIn(['healthy', 'warn']);
|
||||
});
|
||||
|
||||
test('health endpoint returns JSON with timestamp', function () {
|
||||
$response = $this->getJson('/api/health');
|
||||
|
||||
$response->assertOk()->assertJsonStructure(['timestamp']);
|
||||
|
||||
expect($response->json('timestamp'))->toBeString();
|
||||
});
|
||||
|
||||
test('health endpoint reports per-check status keys', function () {
|
||||
$checks = $this->getJson('/api/health')->json('checks');
|
||||
|
||||
foreach (['database', 'redis', 'storage', 'queue'] as $key) {
|
||||
expect($checks)->toHaveKey($key);
|
||||
expect($checks[$key]['status'])->toBeIn(['ok', 'warn', 'fail', 'unknown']);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
test('otp send requires email', function () {
|
||||
$this->postJson('/api/v1/otp/send', [])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('otp send accepts valid email and queues mail', function () {
|
||||
Mail::fake();
|
||||
|
||||
$this->postJson('/api/v1/otp/send', ['email' => 'test@example.com'])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
});
|
||||
|
||||
test('otp verify rejects invalid code', function () {
|
||||
$this->postJson('/api/v1/otp/verify', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => '000000',
|
||||
])->assertStatus(422)
|
||||
->assertJsonPath('status', 'error');
|
||||
});
|
||||
|
||||
test('otp verify requires 6-digit code', function () {
|
||||
$this->postJson('/api/v1/otp/verify', [
|
||||
'email' => 'test@example.com',
|
||||
'code' => '123',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('login screen can be rendered', function () {
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('users can authenticate using the login screen', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
});
|
||||
|
||||
test('users can not authenticate with invalid password', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('users can logout', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect('/');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
test('email verification screen can be rendered', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('email can be verified', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
});
|
||||
|
||||
test('email is not verified with invalid hash', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('confirm password screen can be rendered', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('password can be confirmed', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('password is not confirmed with invalid password', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('password update succeeds with valid current password', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'New-Pass-123',
|
||||
'password_confirmation' => 'New-Pass-123',
|
||||
])
|
||||
->assertRedirect('/profile')
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
expect(Hash::check('New-Pass-123', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('password update stamps password_changed_at', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 0]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('current-pass'),
|
||||
'password_changed_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'New-Pass-456',
|
||||
'password_confirmation' => 'New-Pass-456',
|
||||
]);
|
||||
|
||||
expect($user->fresh()->password_changed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('wrong current password is rejected', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('real-pass')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-pass',
|
||||
'password' => 'New-Pass-789',
|
||||
'password_confirmation' => 'New-Pass-789',
|
||||
])
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password');
|
||||
|
||||
expect(Hash::check('real-pass', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('mismatched confirmation is rejected', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('correct')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'correct',
|
||||
'password' => 'New-Pass-Abc',
|
||||
'password_confirmation' => 'Different-Xyz',
|
||||
])
|
||||
->assertSessionHasErrors();
|
||||
});
|
||||
|
||||
// ── History enforcement ───────────────────────────────────────────────────────
|
||||
|
||||
test('reusing a recent password is rejected with history enabled', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 3]);
|
||||
|
||||
$oldHash = Hash::make('OldPassword1!');
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => $oldHash]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'OldPassword1!',
|
||||
'password_confirmation' => 'OldPassword1!',
|
||||
])
|
||||
->assertSessionHasErrors();
|
||||
});
|
||||
|
||||
test('history check is skipped when history_count is zero', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 0]);
|
||||
|
||||
$oldHash = Hash::make('Recycled-Pass!1');
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => $oldHash]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'Recycled-Pass!1',
|
||||
'password_confirmation' => 'Recycled-Pass!1',
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
// ── History recording ─────────────────────────────────────────────────────────
|
||||
|
||||
test('successful password change records new entry in password history', function () {
|
||||
app(SystemConfigService::class)->update(['password_history_count' => 5]);
|
||||
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
|
||||
$this->actingAs($user)->put('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'Brand-New-777!',
|
||||
'password_confirmation' => 'Brand-New-777!',
|
||||
]);
|
||||
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ── old password no longer works after update ─────────────────────────────────
|
||||
|
||||
test('old password cannot be used to log in after successful update', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('old-pass-456')]);
|
||||
|
||||
$this->actingAs($user)->put('/password', [
|
||||
'current_password' => 'old-pass-456',
|
||||
'password' => 'Brand-New-888!',
|
||||
'password_confirmation' => 'Brand-New-888!',
|
||||
]);
|
||||
|
||||
// Old password must not match the new stored hash
|
||||
expect(Hash::check('old-pass-456', $user->fresh()->password))->toBeFalse();
|
||||
// New password must match
|
||||
expect(Hash::check('Brand-New-888!', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
// ── Guest ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot update password', function () {
|
||||
$this->put('/password', [
|
||||
'current_password' => 'x',
|
||||
'password' => 'y',
|
||||
'password_confirmation' => 'y',
|
||||
])->assertRedirect('/login');
|
||||
});
|
||||
|
||||
// ── JSON response ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('json request receives json success response', function () {
|
||||
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->putJson('/password', [
|
||||
'current_password' => 'current-pass',
|
||||
'password' => 'Json-Pass-1!',
|
||||
'password_confirmation' => 'Json-Pass-1!',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\Auth\ResetPasswordNotification as ResetPassword;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('reset password link screen can be rendered', function () {
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('reset password link can be requested', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
});
|
||||
|
||||
test('reset password screen can be rendered', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('password can be reset with valid token', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
test('password can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
});
|
||||
|
||||
test('correct password must be provided to update password', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password')
|
||||
->assertRedirect('/profile');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
test('registration screen can be rendered', function () {
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('new users can register', function () {
|
||||
// Ensure the default role exists (not seeded in test DB)
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'Password1!',
|
||||
'password_confirmation' => 'Password1!',
|
||||
'agree_tos_pdp' => '1',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Session fixation prevention tests.
|
||||
*
|
||||
* Verifies that all authentication flows regenerate the session ID
|
||||
* after a successful login, preventing session fixation attacks.
|
||||
*/
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
// ── Web login ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('web login regenerates the session id', function () {
|
||||
$user = User::factory()->create();
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// After login the session must have a different ID
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── 2FA verify ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('2fa verify regenerates the session id', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '123456');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/2fa', ['code' => '123456']);
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── OAuth / Socialite callback ────────────────────────────────────────────────
|
||||
|
||||
test('oauth callback regenerates the session id after successful login', function () {
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
app(SystemConfigService::class)->update(['feature_google_oauth' => true]);
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
$socialUser = new Laravel\Socialite\Two\User;
|
||||
$socialUser->id = 'google-fixation-id';
|
||||
$socialUser->name = 'Fixation User';
|
||||
$socialUser->email = 'fixation@example.com';
|
||||
$socialUser->avatar = '';
|
||||
$socialUser->user = [];
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn($socialUser);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── Password reset ────────────────────────────────────────────────────────────
|
||||
|
||||
test('password reset regenerates the session id', function () {
|
||||
Notification::fake();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Request a reset token
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
$token = Password::broker()->createToken($user);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/reset-password', [
|
||||
'token' => $token,
|
||||
'email' => $user->email,
|
||||
'password' => 'NewSecurePass1!',
|
||||
'password_confirmation' => 'NewSecurePass1!',
|
||||
])->assertRedirect(route('login'));
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
// ── Impersonation ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('starting impersonation regenerates the session id', function () {
|
||||
$perm = \App\Models\Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']);
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->givePermissionTo($perm);
|
||||
$target = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->actingAs($admin)->post("/impersonate/{$target->id}");
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
|
||||
test('stopping impersonation regenerates the session id', function () {
|
||||
$perm = \App\Models\Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']);
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->givePermissionTo($perm);
|
||||
$target = User::factory()->create(['is_active' => true]);
|
||||
|
||||
// Start impersonation first
|
||||
$this->actingAs($admin)->post("/impersonate/{$target->id}");
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
$this->post('/impersonate/stop');
|
||||
|
||||
expect(session()->getId())->not->toBe($before);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUserContract;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
});
|
||||
|
||||
function enableOauth(string $provider): void
|
||||
{
|
||||
app(SystemConfigService::class)->update(['feature_'.$provider.'_oauth' => true]);
|
||||
}
|
||||
|
||||
function fakeSocialiteUser(array $overrides = []): SocialiteUserContract
|
||||
{
|
||||
$u = new Laravel\Socialite\Two\User;
|
||||
$u->id = $overrides['id'] ?? 'oauth-id-123';
|
||||
$u->name = $overrides['name'] ?? 'OAuth User';
|
||||
$u->email = $overrides['email'] ?? 'oauth@example.com';
|
||||
$u->avatar = $overrides['avatar'] ?? 'https://example.com/avatar.png';
|
||||
$u->user = $overrides['user'] ?? [];
|
||||
|
||||
return $u;
|
||||
}
|
||||
|
||||
test('redirect returns 404 when provider feature is disabled', function () {
|
||||
$this->get('/auth/google')->assertNotFound();
|
||||
});
|
||||
|
||||
test('redirect issues a redirect when provider feature is enabled', function () {
|
||||
enableOauth('google');
|
||||
|
||||
Socialite::shouldReceive('driver->redirect')
|
||||
->andReturn(redirect('https://accounts.google.com/o/oauth2/auth?fake=1'));
|
||||
|
||||
$this->get('/auth/google')->assertRedirect();
|
||||
expect(session('social_auth_provider'))->toBe('google');
|
||||
});
|
||||
|
||||
test('callback without provider session redirects to login with error', function () {
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
});
|
||||
|
||||
test('callback rejects unverified email from provider', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'user' => ['email_verified' => false],
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('callback creates a new user via provider id and assigns user role', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'google-uid-9',
|
||||
'email' => 'fresh@example.com',
|
||||
'name' => 'Fresh User',
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
|
||||
$user = User::where('email', 'fresh@example.com')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->google_id)->toBe('google-uid-9');
|
||||
expect($user->hasRole('User'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('callback links to existing user with matching email when no provider id yet', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
$existing = User::factory()->create([
|
||||
'email' => 'link@example.com',
|
||||
'google_id' => null,
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'google-uid-link',
|
||||
'email' => 'link@example.com',
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
|
||||
expect($existing->fresh()->google_id)->toBe('google-uid-link');
|
||||
$this->assertAuthenticatedAs($existing->fresh());
|
||||
});
|
||||
|
||||
test('callback refuses to overwrite a different existing oauth identity', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
$existing = User::factory()->create([
|
||||
'email' => 'taken@example.com',
|
||||
'google_id' => 'different-google-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'attacker-id',
|
||||
'email' => 'taken@example.com',
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
|
||||
expect($existing->fresh()->google_id)->toBe('different-google-id');
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('callback re-uses user matched by provider id', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
$existing = User::factory()->create(['google_id' => 'stable-id']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn(fakeSocialiteUser([
|
||||
'id' => 'stable-id',
|
||||
'email' => $existing->email,
|
||||
]));
|
||||
|
||||
$this->get('/auth/callback')->assertRedirect('/dashboard');
|
||||
$this->assertAuthenticatedAs($existing->fresh());
|
||||
});
|
||||
|
||||
test('callback on socialite exception redirects to login with error', function () {
|
||||
enableOauth('google');
|
||||
session(['social_auth_provider' => 'google']);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andThrow(new Exception('OAuth boom'));
|
||||
|
||||
$this->get('/auth/callback')
|
||||
->assertRedirect('/login')
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
test('guest without 2fa session is redirected to login', function () {
|
||||
$this->get('/2fa')->assertRedirect(route('login', absolute: false));
|
||||
});
|
||||
|
||||
test('2fa view renders when 2fa session is set', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '654321');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->get('/2fa')->assertOk()->assertViewIs('auth.two-factor');
|
||||
});
|
||||
|
||||
test('verify with correct code logs the user in', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '111222'])
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('verify with wrong code keeps user logged out', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '999999'])
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('verify with expired code redirects to login', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->subSecond()->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '111222'])
|
||||
->assertRedirect(route('login', absolute: false))
|
||||
->assertSessionHas('error');
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('verify rejects code with wrong length', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '111222');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '12345'])->assertSessionHasErrors('code');
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('trust device option persists a trusted device row', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '888888');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '888888', 'trust_device' => '1']);
|
||||
|
||||
expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('trust device defaults to no row when option is unset', function () {
|
||||
$user = User::factory()->create();
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '777777');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->post('/2fa', ['code' => '777777']);
|
||||
|
||||
expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('trusted device cookie skips 2fa view and auto-logs-in', function () {
|
||||
$user = User::factory()->create();
|
||||
$deviceId = (string) Str::uuid();
|
||||
$secret = Str::random(64);
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => $deviceId,
|
||||
'token' => hash('sha256', $secret),
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '000000');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->withCookie('2fa_trust_device', $deviceId.'|'.$secret)
|
||||
->get('/2fa')
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('trusted device cookie with wrong secret does not auto-login', function () {
|
||||
$user = User::factory()->create();
|
||||
$deviceId = (string) Str::uuid();
|
||||
$realSecret = Str::random(64);
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => $deviceId,
|
||||
'token' => hash('sha256', $realSecret),
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', '000000');
|
||||
Session::put('auth.2fa_expires_at', now()->addMinutes(10)->timestamp);
|
||||
|
||||
$this->withCookie('2fa_trust_device', $deviceId.'|wrong-secret')
|
||||
->get('/2fa')
|
||||
->assertOk();
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\WebAuthn\WebAuthnLoginController;
|
||||
use App\Http\Controllers\WebAuthn\WebAuthnRegisterController;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
test('WebAuthn login controller class exists and has required methods', function () {
|
||||
$ref = new ReflectionClass(WebAuthnLoginController::class);
|
||||
expect($ref->hasMethod('options'))->toBeTrue();
|
||||
expect($ref->hasMethod('login'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('WebAuthn register controller class exists and has required methods', function () {
|
||||
$ref = new ReflectionClass(WebAuthnRegisterController::class);
|
||||
expect($ref->hasMethod('options'))->toBeTrue();
|
||||
expect($ref->hasMethod('register'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('webauthn_enabled setting defaults to false in fresh DB', function () {
|
||||
expect(get_setting('webauthn_enabled', false))->toBeFalse();
|
||||
});
|
||||
|
||||
test('webauthn_enabled setting can be toggled on', function () {
|
||||
app(SystemConfigService::class)->update(['webauthn_enabled' => true]);
|
||||
|
||||
expect(get_setting('webauthn_enabled', false))->toBeTrue();
|
||||
});
|
||||
|
||||
test('webauthn_credentials migration created the laragear table', function () {
|
||||
expect(Schema::hasTable('webauthn_credentials'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('webauthn_credentials table has the expected key columns', function () {
|
||||
$cols = Schema::getColumnListing('webauthn_credentials');
|
||||
|
||||
foreach (['id', 'authenticatable_id', 'authenticatable_type'] as $required) {
|
||||
expect($cols)->toContain($required);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\UserConsent;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
test('force deleting a user cascades password_histories', function () {
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('a')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('b')]);
|
||||
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(2);
|
||||
|
||||
$user->forceDelete();
|
||||
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('force deleting a user cascades user_consents', function () {
|
||||
$user = User::factory()->create();
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'tos',
|
||||
'version_id' => 1,
|
||||
'ip_address' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
expect(UserConsent::where('user_id', $user->id)->count())->toBe(1);
|
||||
|
||||
$user->forceDelete();
|
||||
|
||||
expect(UserConsent::where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('force deleting a user cascades user_trusted_devices', function () {
|
||||
$user = User::factory()->create();
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => 'dev-1',
|
||||
'token' => 'tok',
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$user->forceDelete();
|
||||
|
||||
expect(UserTrustedDevice::where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('force deleting a user nulls system_settings audit columns', function () {
|
||||
$actor = User::factory()->create();
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'cascade_test',
|
||||
'value' => 'x',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => false,
|
||||
'created_by' => $actor->id,
|
||||
'updated_by' => $actor->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$actor->forceDelete();
|
||||
|
||||
$row = DB::table('system_settings')->where('key', 'cascade_test')->first();
|
||||
expect($row->created_by)->toBeNull();
|
||||
expect($row->updated_by)->toBeNull();
|
||||
});
|
||||
|
||||
test('force deleting a user nulls system_setting_revisions.changed_by', function () {
|
||||
$actor = User::factory()->create();
|
||||
$settingId = DB::table('system_settings')->insertGetId([
|
||||
'key' => 'cascade_test_2',
|
||||
'value' => 'x',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('system_setting_revisions')->insert([
|
||||
'system_setting_id' => $settingId,
|
||||
'key' => 'cascade_test_2',
|
||||
'old_value' => null,
|
||||
'new_value' => '"x"',
|
||||
'changed_by' => $actor->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$actor->forceDelete();
|
||||
|
||||
expect(DB::table('system_setting_revisions')->where('key', 'cascade_test_2')->value('changed_by'))
|
||||
->toBeNull();
|
||||
});
|
||||
|
||||
test('deleting a role removes role_has_permissions linkage', function () {
|
||||
$role = Role::create(['name' => 'cascade-role', 'guard_name' => 'web']);
|
||||
$perm = Permission::firstOrCreate(['name' => 'cascade-perm', 'guard_name' => 'web']);
|
||||
$role->givePermissionTo($perm);
|
||||
|
||||
expect(DB::table('role_has_permissions')->where('role_id', $role->id)->count())->toBe(1);
|
||||
|
||||
$role->forceDelete();
|
||||
|
||||
expect(DB::table('role_has_permissions')->where('role_id', $role->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('deleting a permission removes role_has_permissions linkage', function () {
|
||||
$role = Role::create(['name' => 'host-role', 'guard_name' => 'web']);
|
||||
$perm = Permission::firstOrCreate(['name' => 'doomed-perm', 'guard_name' => 'web']);
|
||||
$role->givePermissionTo($perm);
|
||||
|
||||
$perm->forceDelete();
|
||||
|
||||
expect(DB::table('role_has_permissions')->where('permission_id', $perm->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('roles audit columns null out when the actor is force-deleted', function () {
|
||||
$actor = User::factory()->create();
|
||||
$role = Role::create(['name' => 'audit-role', 'guard_name' => 'web']);
|
||||
DB::table('roles')->where('id', $role->id)->update([
|
||||
'created_by' => $actor->id,
|
||||
'updated_by' => $actor->id,
|
||||
]);
|
||||
|
||||
$actor->forceDelete();
|
||||
|
||||
$row = DB::table('roles')->where('id', $role->id)->first();
|
||||
expect($row->created_by)->toBeNull();
|
||||
expect($row->updated_by)->toBeNull();
|
||||
});
|
||||
|
||||
test('soft-deleting a user keeps related rows intact', function () {
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('a')]);
|
||||
|
||||
$user->delete(); // soft
|
||||
|
||||
expect(User::withTrashed()->find($user->id)->trashed())->toBeTrue();
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
it('returns a successful response', function () {
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
|
||||
test('success returns 200 with status and message', function () {
|
||||
$r = ApiResponse::success(['x' => 1], 'OK');
|
||||
expect($r->getStatusCode())->toBe(200);
|
||||
$body = $r->getData(true);
|
||||
expect($body['status'])->toBe('success');
|
||||
expect($body['message'])->toBe('OK');
|
||||
expect($body['data'])->toBe(['x' => 1]);
|
||||
});
|
||||
|
||||
test('success omits data key when data is null', function () {
|
||||
$body = ApiResponse::success(null, 'no body')->getData(true);
|
||||
expect($body)->not->toHaveKey('data');
|
||||
});
|
||||
|
||||
test('error returns the given code and message', function () {
|
||||
$r = ApiResponse::error('Boom', 418);
|
||||
expect($r->getStatusCode())->toBe(418);
|
||||
$body = $r->getData(true);
|
||||
expect($body['status'])->toBe('error');
|
||||
expect($body['message'])->toBe('Boom');
|
||||
});
|
||||
|
||||
test('error includes errors array when provided', function () {
|
||||
$body = ApiResponse::error('Bad', 422, ['field' => 'is broken'])->getData(true);
|
||||
expect($body['errors'])->toBe(['field' => 'is broken']);
|
||||
});
|
||||
|
||||
test('created returns 201', function () {
|
||||
expect(ApiResponse::created(['id' => 5])->getStatusCode())->toBe(201);
|
||||
});
|
||||
|
||||
test('notFound returns 404 with default message', function () {
|
||||
$r = ApiResponse::notFound();
|
||||
expect($r->getStatusCode())->toBe(404);
|
||||
expect($r->getData(true)['message'])->toBe('Resource not found');
|
||||
});
|
||||
|
||||
test('unauthorized returns 401', function () {
|
||||
expect(ApiResponse::unauthorized()->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
test('forbidden returns 403', function () {
|
||||
expect(ApiResponse::forbidden()->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
test('validationError returns 422 with errors payload', function () {
|
||||
$r = ApiResponse::validationError(['email' => ['required']]);
|
||||
expect($r->getStatusCode())->toBe(422);
|
||||
expect($r->getData(true)['errors'])->toBe(['email' => ['required']]);
|
||||
});
|
||||
|
||||
test('serverError returns 500', function () {
|
||||
expect(ApiResponse::serverError()->getStatusCode())->toBe(500);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\PasswordRuleHelper;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
test('rules array includes required, string, min:12', function () {
|
||||
$rules = PasswordRuleHelper::rules();
|
||||
expect($rules)->toContain('required');
|
||||
expect($rules)->toContain('string');
|
||||
expect($rules)->toContain('min:12');
|
||||
});
|
||||
|
||||
test('rules array includes regex for each charset class', function () {
|
||||
$rules = PasswordRuleHelper::rules();
|
||||
expect(implode('|', $rules))
|
||||
->toContain('regex:/[a-z]/')
|
||||
->toContain('regex:/[A-Z]/')
|
||||
->toContain('regex:/[0-9]/');
|
||||
});
|
||||
|
||||
test('messages contain min and regex keys', function () {
|
||||
$msgs = PasswordRuleHelper::messages();
|
||||
expect($msgs)->toHaveKeys(['password.min', 'password.regex']);
|
||||
});
|
||||
|
||||
test('rules accept a strong password via Validator', function () {
|
||||
$v = Validator::make(
|
||||
['password' => 'Str0ng!Passw0rd2026'],
|
||||
['password' => PasswordRuleHelper::rules()],
|
||||
PasswordRuleHelper::messages(),
|
||||
);
|
||||
expect($v->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('rules reject short passwords', function () {
|
||||
$v = Validator::make(
|
||||
['password' => 'Abc123!'],
|
||||
['password' => PasswordRuleHelper::rules()],
|
||||
);
|
||||
expect($v->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rules reject passwords missing uppercase', function () {
|
||||
$v = Validator::make(
|
||||
['password' => 'all_lower_123!'],
|
||||
['password' => PasswordRuleHelper::rules()],
|
||||
);
|
||||
expect($v->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rules reject passwords missing digit', function () {
|
||||
$v = Validator::make(
|
||||
['password' => 'NoDigitsHere!'],
|
||||
['password' => PasswordRuleHelper::rules()],
|
||||
);
|
||||
expect($v->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rules reject passwords missing symbol', function () {
|
||||
$v = Validator::make(
|
||||
['password' => 'NoSymbolsHere1'],
|
||||
['password' => PasswordRuleHelper::rules()],
|
||||
);
|
||||
expect($v->fails())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
|
||||
function makeImpersonator(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$perm = Permission::firstOrCreate(['name' => 'impersonate users', 'guard_name' => 'web']);
|
||||
$admin->givePermissionTo($perm);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('guest cannot start impersonation', function () {
|
||||
$target = User::factory()->create();
|
||||
|
||||
$this->post("/impersonate/{$target->id}")->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission cannot start impersonation', function () {
|
||||
$admin = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->post("/impersonate/{$target->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
test('cannot impersonate self', function () {
|
||||
$admin = makeImpersonator();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post("/impersonate/{$admin->id}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('cannot impersonate an inactive user', function () {
|
||||
$admin = makeImpersonator();
|
||||
$target = User::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post("/impersonate/{$target->id}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('cannot impersonate a Developer (Super Admin)', function () {
|
||||
$admin = makeImpersonator();
|
||||
$dev = User::factory()->create(['is_active' => true]);
|
||||
Role::firstOrCreate(['name' => 'Developer', 'guard_name' => 'web']);
|
||||
$dev->assignRole('Developer');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post("/impersonate/{$dev->id}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('start switches the authenticated user and stashes original id', function () {
|
||||
$admin = makeImpersonator();
|
||||
$target = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post("/impersonate/{$target->id}")
|
||||
->assertRedirect(route('dashboard', absolute: false))
|
||||
->assertSessionHas('impersonator_id', $admin->id);
|
||||
|
||||
expect(auth()->id())->toBe($target->id);
|
||||
});
|
||||
|
||||
test('cannot start a second impersonation while already impersonating', function () {
|
||||
$admin = makeImpersonator();
|
||||
$first = User::factory()->create(['is_active' => true]);
|
||||
$first->givePermissionTo('impersonate users'); // edge case: target also has permission
|
||||
$second = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($admin)->post("/impersonate/{$first->id}");
|
||||
|
||||
$this->post("/impersonate/{$second->id}")
|
||||
->assertRedirect()
|
||||
->assertSessionHas('error');
|
||||
|
||||
expect(auth()->id())->toBe($first->id);
|
||||
});
|
||||
|
||||
test('stop without an active session returns 403', function () {
|
||||
$admin = makeImpersonator();
|
||||
|
||||
$this->actingAs($admin)->post('/impersonate/stop')->assertForbidden();
|
||||
});
|
||||
|
||||
test('stop restores the original super admin', function () {
|
||||
$admin = makeImpersonator();
|
||||
$target = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($admin)->post("/impersonate/{$target->id}");
|
||||
expect(auth()->id())->toBe($target->id);
|
||||
|
||||
$this->post('/impersonate/stop')
|
||||
->assertRedirect(route('users', absolute: false));
|
||||
|
||||
expect(auth()->id())->toBe($admin->id);
|
||||
expect(session()->has('impersonator_id'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
|
||||
Route::middleware(['web', 'auth', 'active-permission:probe'])
|
||||
->get('/__probe', fn () => response('ok'))
|
||||
->name('test.probe');
|
||||
});
|
||||
|
||||
test('inactive permission returns 403 even when user has it', function () {
|
||||
Permission::firstOrCreate(['name' => 'probe', 'guard_name' => 'web', 'is_active' => false]);
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('probe');
|
||||
|
||||
$this->actingAs($user)->get('/__probe')->assertForbidden();
|
||||
});
|
||||
|
||||
test('active permission allows the request through', function () {
|
||||
Permission::firstOrCreate(['name' => 'probe', 'guard_name' => 'web', 'is_active' => true]);
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('probe');
|
||||
|
||||
$this->actingAs($user)->get('/__probe')->assertOk()->assertSeeText('ok');
|
||||
});
|
||||
|
||||
test('missing permission returns 403', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/__probe')->assertForbidden();
|
||||
});
|
||||
|
||||
test('cache is consulted on subsequent hits', function () {
|
||||
Permission::firstOrCreate(['name' => 'probe', 'guard_name' => 'web', 'is_active' => true]);
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('probe');
|
||||
|
||||
$this->actingAs($user)->get('/__probe')->assertOk();
|
||||
|
||||
expect(Cache::has('permission_status:probe'))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckLegalAgreement;
|
||||
use App\Models\User;
|
||||
use App\Models\UserConsent;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
// Pest.php disables CheckLegalAgreement globally for Feature tests — re-enable it here.
|
||||
$this->withMiddleware(CheckLegalAgreement::class);
|
||||
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
|
||||
Route::middleware(['web', 'auth', CheckLegalAgreement::class])
|
||||
->get('/__legal-probe', fn () => response('ok'));
|
||||
});
|
||||
|
||||
function setLegalVersion(string $prefix, int $version): void
|
||||
{
|
||||
app(SystemConfigService::class)->update(["{$prefix}_document_version" => $version]);
|
||||
}
|
||||
|
||||
test('guest is unaffected by middleware', function () {
|
||||
$this->get('/__legal-probe')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without consent is redirected to re-agree', function () {
|
||||
setLegalVersion('tos', 1);
|
||||
setLegalVersion('pdp', 1);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/__legal-probe')
|
||||
->assertRedirect(route('legal.re-agree', absolute: false));
|
||||
});
|
||||
|
||||
test('user with current consent passes through', function () {
|
||||
setLegalVersion('tos', 1);
|
||||
setLegalVersion('pdp', 1);
|
||||
$user = User::factory()->create();
|
||||
UserConsent::create(['user_id' => $user->id, 'consent_type' => 'tos', 'version_id' => 1, 'ip_address' => '127.0.0.1']);
|
||||
UserConsent::create(['user_id' => $user->id, 'consent_type' => 'privacy', 'version_id' => 1, 'ip_address' => '127.0.0.1']);
|
||||
|
||||
$this->actingAs($user)->get('/__legal-probe')->assertOk();
|
||||
});
|
||||
|
||||
test('user with outdated consent is redirected', function () {
|
||||
setLegalVersion('tos', 2);
|
||||
setLegalVersion('pdp', 2);
|
||||
$user = User::factory()->create();
|
||||
UserConsent::create(['user_id' => $user->id, 'consent_type' => 'tos', 'version_id' => 1, 'ip_address' => '127.0.0.1']);
|
||||
UserConsent::create(['user_id' => $user->id, 'consent_type' => 'privacy', 'version_id' => 1, 'ip_address' => '127.0.0.1']);
|
||||
|
||||
$this->actingAs($user)->get('/__legal-probe')
|
||||
->assertRedirect(route('legal.re-agree', absolute: false));
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\IpAccessControl;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
|
||||
Route::middleware([IpAccessControl::class])
|
||||
->get('/__ip-probe', fn () => response('ok'))
|
||||
->name('test.ip-probe');
|
||||
|
||||
Route::middleware([IpAccessControl::class])
|
||||
->get('/users/__ip-probe', fn () => response('ok-users'));
|
||||
});
|
||||
|
||||
function setIpSetting(string $key, mixed $value): void
|
||||
{
|
||||
app(SystemConfigService::class)->update([$key => $value]);
|
||||
}
|
||||
|
||||
test('request passes through with no IP rules configured', function () {
|
||||
$this->get('/__ip-probe')->assertOk()->assertSeeText('ok');
|
||||
});
|
||||
|
||||
test('blacklisted IP gets 403', function () {
|
||||
setIpSetting('ip_blacklist', '127.0.0.1, 10.0.0.5');
|
||||
|
||||
$this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('non-blacklisted IP passes through', function () {
|
||||
setIpSetting('ip_blacklist', '10.0.0.5');
|
||||
|
||||
$this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertOk();
|
||||
});
|
||||
|
||||
test('admin whitelist denies non-whitelisted IPs on admin routes', function () {
|
||||
setIpSetting('ip_whitelist_admin', '203.0.113.1');
|
||||
|
||||
$this->call('GET', '/users/__ip-probe', server: ['REMOTE_ADDR' => '127.0.0.1'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin whitelist permits whitelisted IPs on admin routes', function () {
|
||||
setIpSetting('ip_whitelist_admin', '127.0.0.1');
|
||||
|
||||
$this->call('GET', '/users/__ip-probe', server: ['REMOTE_ADDR' => '127.0.0.1'])->assertOk();
|
||||
});
|
||||
|
||||
test('admin whitelist does not affect non-admin routes', function () {
|
||||
setIpSetting('ip_whitelist_admin', '203.0.113.1');
|
||||
|
||||
$this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertOk();
|
||||
});
|
||||
|
||||
test('auto-blocked IP returns 429', function () {
|
||||
setIpSetting('auto_block_ip', true);
|
||||
Cache::put('ip_block:127.0.0.1', true, now()->addHour());
|
||||
|
||||
$this->get('/__ip-probe', ['REMOTE_ADDR' => '127.0.0.1'])->assertStatus(429);
|
||||
});
|
||||
|
||||
test('single session enforcement logs out stale session', function () {
|
||||
setIpSetting('session_single_session', true);
|
||||
$user = User::factory()->create(['last_session_id' => 'OTHER_SESSION_ID']);
|
||||
|
||||
$this->actingAs($user)->get('/__ip-probe')
|
||||
->assertRedirect(route('login', absolute: false));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\PasswordExpiryMiddleware;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
|
||||
Route::middleware(['web', 'auth', PasswordExpiryMiddleware::class])
|
||||
->get('/__pwd-probe', fn () => response('ok'));
|
||||
});
|
||||
|
||||
function setExpirySetting(int $days): void
|
||||
{
|
||||
app(SystemConfigService::class)->update(['password_expiry_days' => $days]);
|
||||
}
|
||||
|
||||
test('user with fresh password passes through', function () {
|
||||
setExpirySetting(30);
|
||||
$user = User::factory()->create();
|
||||
DB::table('users')->where('id', $user->id)
|
||||
->update(['password_changed_at' => now()->subDays(5)]);
|
||||
|
||||
$this->actingAs($user->fresh())->get('/__pwd-probe')->assertOk();
|
||||
});
|
||||
|
||||
test('user with expired password is redirected to profile', function () {
|
||||
setExpirySetting(30);
|
||||
$user = User::factory()->create();
|
||||
DB::table('users')->where('id', $user->id)
|
||||
->update(['password_changed_at' => now()->subDays(40)]);
|
||||
|
||||
$this->actingAs($user->fresh())->get('/__pwd-probe')
|
||||
->assertRedirect(route('profile.edit', absolute: false))
|
||||
->assertSessionHas('warning');
|
||||
});
|
||||
|
||||
test('expiry disabled (0 days) never redirects', function () {
|
||||
setExpirySetting(0);
|
||||
$user = User::factory()->create();
|
||||
DB::table('users')->where('id', $user->id)
|
||||
->update(['password_changed_at' => now()->subYears(2)]);
|
||||
|
||||
$this->actingAs($user->fresh())->get('/__pwd-probe')->assertOk();
|
||||
});
|
||||
|
||||
test('guest is unaffected', function () {
|
||||
$this->get('/__pwd-probe')->assertRedirect('/login');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
|
||||
Route::middleware('web')
|
||||
->get('/__sec-probe', fn () => response('ok'));
|
||||
});
|
||||
|
||||
test('X-Content-Type-Options nosniff is present', function () {
|
||||
$r = $this->get('/__sec-probe');
|
||||
expect($r->headers->get('X-Content-Type-Options'))->toBe('nosniff');
|
||||
});
|
||||
|
||||
test('X-Frame-Options SAMEORIGIN is present', function () {
|
||||
$r = $this->get('/__sec-probe');
|
||||
expect($r->headers->get('X-Frame-Options'))->toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
test('Referrer-Policy is strict-origin-when-cross-origin', function () {
|
||||
$r = $this->get('/__sec-probe');
|
||||
expect($r->headers->get('Referrer-Policy'))->toBe('strict-origin-when-cross-origin');
|
||||
});
|
||||
|
||||
test('Permissions-Policy locks down camera, microphone, geolocation', function () {
|
||||
$r = $this->get('/__sec-probe');
|
||||
$pp = $r->headers->get('Permissions-Policy');
|
||||
expect($pp)->toContain('camera=()')->toContain('microphone=()')->toContain('geolocation=()');
|
||||
});
|
||||
|
||||
test('X-XSS-Protection header is set', function () {
|
||||
$r = $this->get('/__sec-probe');
|
||||
expect($r->headers->get('X-XSS-Protection'))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('HSTS is omitted over plain HTTP regardless of setting', function () {
|
||||
$r = $this->get('/__sec-probe');
|
||||
expect($r->headers->get('Strict-Transport-Security'))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
test('mobile sync returns expected envelope keys', function () {
|
||||
$response = $this->getJson('/api/v1/mobile/sync');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['status', 'version', 'last_updated', 'data']);
|
||||
|
||||
expect($response->json('status'))->toBe('success');
|
||||
expect($response->json('data'))->toBeArray()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('mobile sync responds with ETag header', function () {
|
||||
$response = $this->getJson('/api/v1/mobile/sync');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->headers->get('ETag'))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('mobile sync returns 304 when If-None-Match matches', function () {
|
||||
$etag = $this->getJson('/api/v1/mobile/sync')->headers->get('ETag');
|
||||
|
||||
$this->withHeaders(['If-None-Match' => $etag])
|
||||
->getJson('/api/v1/mobile/sync')
|
||||
->assertStatus(304);
|
||||
});
|
||||
|
||||
test('mobile sync is cached', function () {
|
||||
$this->getJson('/api/v1/mobile/sync');
|
||||
|
||||
expect(Cache::has('mobile_config_all'))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AI\AiUsageLog;
|
||||
use App\Models\AiHealingLog;
|
||||
use App\Models\MobileErrorLog;
|
||||
use App\Models\MobileSyncLog;
|
||||
use App\Models\Notification;
|
||||
use App\Models\OtpCode;
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
// ── OtpCode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test('OtpCode prunable selects only expired records', function () {
|
||||
OtpCode::create(['identifier' => 'a@a.com', 'code' => '111111', 'expires_at' => now()->subMinute()]);
|
||||
OtpCode::create(['identifier' => 'b@b.com', 'code' => '222222', 'expires_at' => now()->addHour()]);
|
||||
|
||||
$prunableIds = (new OtpCode)->prunable()->pluck('id');
|
||||
|
||||
expect($prunableIds)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('OtpCode prunable returns a Builder instance', function () {
|
||||
expect((new OtpCode)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── UserTrustedDevice ─────────────────────────────────────────────────────────
|
||||
|
||||
test('UserTrustedDevice prunable selects expired devices only', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => 'old-device',
|
||||
'token' => 'tok1',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => 'fresh-device',
|
||||
'token' => 'tok2',
|
||||
'expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$ids = (new UserTrustedDevice)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('UserTrustedDevice prunable returns a Builder instance', function () {
|
||||
expect((new UserTrustedDevice)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── PasswordHistory ───────────────────────────────────────────────────────────
|
||||
|
||||
test('PasswordHistory prunable selects records older than 365 days', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
\DB::table('password_histories')->insert([
|
||||
['user_id' => $user->id, 'password' => 'h1', 'created_at' => now()->subDays(400), 'updated_at' => now()],
|
||||
['user_id' => $user->id, 'password' => 'h2', 'created_at' => now()->subDays(100), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$ids = (new PasswordHistory)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('PasswordHistory prunable returns a Builder instance', function () {
|
||||
expect((new PasswordHistory)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── MobileSyncLog ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('MobileSyncLog prunable selects records older than 30 days', function () {
|
||||
\DB::table('mobile_sync_logs')->insert([
|
||||
['synced_at' => now()->subDays(35)],
|
||||
['synced_at' => now()->subDays(10)],
|
||||
]);
|
||||
|
||||
$ids = (new MobileSyncLog)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('MobileSyncLog prunable returns a Builder instance', function () {
|
||||
expect((new MobileSyncLog)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── MobileErrorLog ────────────────────────────────────────────────────────────
|
||||
|
||||
test('MobileErrorLog prunable selects records older than 90 days', function () {
|
||||
\DB::table('mobile_error_logs')->insert([
|
||||
['message' => 'old error', 'occurred_at' => now()->subDays(100)],
|
||||
['message' => 'new error', 'occurred_at' => now()->subDays(5)],
|
||||
]);
|
||||
|
||||
$ids = (new MobileErrorLog)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('MobileErrorLog prunable returns a Builder instance', function () {
|
||||
expect((new MobileErrorLog)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── Notification (system_notifications) ──────────────────────────────────────
|
||||
|
||||
test('Notification prunable selects records older than 30 days', function () {
|
||||
\DB::table('system_notifications')->insert([
|
||||
['title' => 'old', 'message' => 'x', 'recipient' => 'all', 'type' => 'info', 'created_at' => now()->subDays(40), 'updated_at' => now()],
|
||||
['title' => 'new', 'message' => 'y', 'recipient' => 'all', 'type' => 'info', 'created_at' => now()->subDays(5), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$ids = (new Notification)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Notification prunable returns a Builder instance', function () {
|
||||
expect((new Notification)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── AiHealingLog ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('AiHealingLog prunable selects records older than 90 days', function () {
|
||||
\DB::table('ai_healing_logs')->insert([
|
||||
['error_message' => 'old', 'created_at' => now()->subDays(100), 'updated_at' => now()],
|
||||
['error_message' => 'new', 'created_at' => now()->subDays(10), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$ids = (new AiHealingLog)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AiHealingLog prunable returns a Builder instance', function () {
|
||||
expect((new AiHealingLog)->prunable())->toBeInstanceOf(Builder::class);
|
||||
});
|
||||
|
||||
// ── AiUsageLog ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('AiUsageLog prunable selects records older than 3 months', function () {
|
||||
\DB::table('ai_usage_logs')->insert([
|
||||
['provider' => 'openai', 'model' => 'gpt-4', 'status' => 'success', 'created_at' => now()->subMonths(4), 'updated_at' => now()],
|
||||
['provider' => 'openai', 'model' => 'gpt-4', 'status' => 'success', 'created_at' => now()->subWeek(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$ids = (new AiUsageLog)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toHaveCount(1);
|
||||
});
|
||||
|
||||
// ── Edge cases ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('OtpCode prunable returns empty when all codes are still valid', function () {
|
||||
OtpCode::create(['identifier' => 'c@c.com', 'code' => '333333', 'expires_at' => now()->addHour()]);
|
||||
|
||||
$ids = (new OtpCode)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('UserTrustedDevice prunable returns empty when no devices are expired', function () {
|
||||
$user = User::factory()->create();
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $user->id,
|
||||
'device_id' => 'good-device',
|
||||
'token' => 'tok3',
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$ids = (new UserTrustedDevice)->prunable()->pluck('id');
|
||||
|
||||
expect($ids)->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function grantViewAndManageAccessRights(User $user): void
|
||||
{
|
||||
foreach (['view access rights', 'manage access rights', 'view user directory', 'manage user directory'] as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$user->givePermissionTo(['view access rights', 'manage access rights', 'view user directory', 'manage user directory']);
|
||||
}
|
||||
|
||||
function queryCountDuring(callable $fn): int
|
||||
{
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$fn();
|
||||
$count = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
test('users datatable query count is bounded regardless of row count', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantViewAndManageAccessRights($admin);
|
||||
User::factory()->count(5)->create()->each(function ($u) {
|
||||
$u->assignRole(Role::firstOrCreate(['name' => 'member-'.$u->id, 'guard_name' => 'web'])->name);
|
||||
});
|
||||
|
||||
$baselineCount = queryCountDuring(function () use ($admin) {
|
||||
$this->actingAs($admin)->getJson('/users?draw=1&start=0&length=10&columns[0][data]=id');
|
||||
});
|
||||
|
||||
User::factory()->count(20)->create()->each(function ($u) {
|
||||
$u->assignRole(Role::firstOrCreate(['name' => 'member-'.$u->id, 'guard_name' => 'web'])->name);
|
||||
});
|
||||
|
||||
$scaledCount = queryCountDuring(function () use ($admin) {
|
||||
$this->actingAs($admin)->getJson('/users?draw=1&start=0&length=10&columns[0][data]=id');
|
||||
});
|
||||
|
||||
// 4x more rows should not 4x the query count — eager loading caps it.
|
||||
expect($scaledCount - $baselineCount)->toBeLessThan(10);
|
||||
});
|
||||
|
||||
test('roles datatable eagerly loads permissions and creator', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantViewAndManageAccessRights($admin);
|
||||
|
||||
foreach (range(1, 5) as $i) {
|
||||
$r = Role::create(['name' => 'np1-role-'.$i, 'guard_name' => 'web']);
|
||||
$p = Permission::firstOrCreate(['name' => 'np1-perm-'.$i, 'guard_name' => 'web']);
|
||||
$r->givePermissionTo($p);
|
||||
}
|
||||
|
||||
$count = queryCountDuring(function () use ($admin) {
|
||||
$this->actingAs($admin)->getJson('/roles?draw=1&start=0&length=10&columns[0][data]=id');
|
||||
});
|
||||
|
||||
// Should not run a permission lookup per row.
|
||||
expect($count)->toBeLessThan(20);
|
||||
});
|
||||
|
||||
test('permissions datatable eagerly loads roles and creator', function () {
|
||||
$admin = User::factory()->create();
|
||||
grantViewAndManageAccessRights($admin);
|
||||
|
||||
foreach (range(1, 8) as $i) {
|
||||
Permission::firstOrCreate(['name' => 'eager-perm-'.$i, 'guard_name' => 'web']);
|
||||
}
|
||||
|
||||
$count = queryCountDuring(function () use ($admin) {
|
||||
$this->actingAs($admin)->getJson('/permissions?draw=1&start=0&length=10&columns[0][data]=id');
|
||||
});
|
||||
|
||||
expect($count)->toBeLessThan(20);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('profile page is displayed', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/profile');
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('profile information can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
});
|
||||
|
||||
test('email verification status is unchanged when the email address is unchanged', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
});
|
||||
|
||||
test('user can delete their account', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/profile', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
// User has SoftDeletes — deleted_at is set, not hard-deleted
|
||||
$this->assertNotNull($user->fresh()->deleted_at);
|
||||
});
|
||||
|
||||
test('correct password must be provided to delete account', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('userDeletion', 'password')
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
beforeEach(function () {
|
||||
// Pest.php disables ThrottleRequests globally — re-enable for these tests.
|
||||
$this->withMiddleware(ThrottleRequests::class);
|
||||
RateLimiter::clear('login');
|
||||
});
|
||||
|
||||
test('API login throttle eventually blocks after enough failed attempts', function () {
|
||||
// The login endpoint stacks two protections: middleware throttle:10,1 and a
|
||||
// controller-level RateLimiter keyed by IP+email (configurable via
|
||||
// security_auth.login_max_attempts, default 5). After enough hits, one of
|
||||
// them must kick in and return 429.
|
||||
$sawBlock = false;
|
||||
for ($i = 0; $i < 15; $i++) {
|
||||
$r = $this->postJson('/api/v1/login', ['email' => 'x@x.com', 'password' => 'wrong']);
|
||||
if ($r->getStatusCode() === 429) {
|
||||
$sawBlock = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect($sawBlock)->toBeTrue();
|
||||
});
|
||||
|
||||
test('API forgot-password throttle blocks after 5 requests', function () {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/v1/forgot-password', ['email' => 'x@x.com']);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/forgot-password', ['email' => 'x@x.com'])
|
||||
->assertStatus(429);
|
||||
});
|
||||
|
||||
test('API OTP send throttle blocks after 5 requests', function () {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/v1/otp/send', ['email' => 'x@x.com']);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/otp/send', ['email' => 'x@x.com'])
|
||||
->assertStatus(429);
|
||||
});
|
||||
|
||||
test('API register throttle blocks after 5 requests', function () {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/v1/register', []);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/register', [])->assertStatus(429);
|
||||
});
|
||||
|
||||
test('2FA verify throttle blocks after 5 requests', function () {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/2fa', ['code' => '123456']);
|
||||
}
|
||||
|
||||
$this->post('/2fa', ['code' => '123456'])->assertStatus(429);
|
||||
});
|
||||
|
||||
test('different IPs do not share the same rate-limit bucket', function () {
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->call('POST', '/api/v1/forgot-password',
|
||||
parameters: ['email' => 'x@x.com'],
|
||||
server: ['REMOTE_ADDR' => '10.0.0.1']
|
||||
);
|
||||
}
|
||||
|
||||
// Same email, different IP — should be fine
|
||||
$r = $this->call('POST', '/api/v1/forgot-password',
|
||||
parameters: ['email' => 'x@x.com'],
|
||||
server: ['REMOTE_ADDR' => '10.0.0.2']
|
||||
);
|
||||
expect($r->getStatusCode())->not->toBe(429);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PasswordHistory;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
app(SystemConfigService::class)->update([$key => $value]);
|
||||
}
|
||||
|
||||
test('isPasswordExpired returns false when expiry is disabled', function () {
|
||||
setSetting('password_expiry_days', 0);
|
||||
$user = User::factory()->create([
|
||||
'password_changed_at' => now()->subYears(5),
|
||||
]);
|
||||
|
||||
expect(PasswordPolicyService::isPasswordExpired($user))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isPasswordExpired uses password_changed_at when present', function () {
|
||||
setSetting('password_expiry_days', 30);
|
||||
$expired = User::factory()->create(['password_changed_at' => now()->subDays(31)]);
|
||||
$fresh = User::factory()->create(['password_changed_at' => now()->subDays(5)]);
|
||||
|
||||
expect(PasswordPolicyService::isPasswordExpired($expired))->toBeTrue();
|
||||
expect(PasswordPolicyService::isPasswordExpired($fresh))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isPasswordExpired falls back to created_at when password_changed_at is null', function () {
|
||||
setSetting('password_expiry_days', 30);
|
||||
$user = User::factory()->create();
|
||||
DB::table('users')->where('id', $user->id)->update([
|
||||
'password_changed_at' => null,
|
||||
'created_at' => now()->subDays(40),
|
||||
]);
|
||||
|
||||
expect(PasswordPolicyService::isPasswordExpired($user->fresh()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('checkHistory is a no-op when history count is zero', function () {
|
||||
setSetting('password_history_count', 0);
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-pass')]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'old-pass');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('checkHistory throws when reusing a recent password', function () {
|
||||
setSetting('password_history_count', 3);
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('reused-pass')]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'reused-pass');
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
test('checkHistory passes when new password is different from history', function () {
|
||||
setSetting('password_history_count', 3);
|
||||
$user = User::factory()->create();
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-1')]);
|
||||
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('old-2')]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'totally-different');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('checkHistory only inspects the most recent N entries', function () {
|
||||
setSetting('password_history_count', 2);
|
||||
$user = User::factory()->create();
|
||||
|
||||
DB::table('password_histories')->insert([
|
||||
['user_id' => $user->id, 'password' => Hash::make('ancient'), 'created_at' => now()->subDays(10), 'updated_at' => now()->subDays(10)],
|
||||
['user_id' => $user->id, 'password' => Hash::make('recent-1'), 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2)],
|
||||
['user_id' => $user->id, 'password' => Hash::make('recent-2'), 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()],
|
||||
]);
|
||||
|
||||
PasswordPolicyService::checkHistory($user, 'ancient');
|
||||
})->throwsNoExceptions();
|
||||
|
||||
test('recordPasswordChange creates history row and stamps password_changed_at', function () {
|
||||
setSetting('password_history_count', 5);
|
||||
$user = User::factory()->create(['password_changed_at' => null]);
|
||||
|
||||
PasswordPolicyService::recordPasswordChange($user, Hash::make('new-pass'));
|
||||
|
||||
expect($user->fresh()->password_changed_at)->not->toBeNull();
|
||||
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('getRules respects min/max length from settings', function () {
|
||||
setSetting('password_min_length', 10);
|
||||
setSetting('password_max_length', 50);
|
||||
|
||||
$rules = PasswordPolicyService::getRules();
|
||||
|
||||
expect($rules)->toBeInstanceOf(Password::class);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Services\System\BackupManagementService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use League\Flysystem\UnableToCreateDirectory;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
$this->service = app(BackupManagementService::class);
|
||||
});
|
||||
|
||||
test('checkRequirements returns ok shape with binary status', function () {
|
||||
$result = $this->service->checkRequirements();
|
||||
|
||||
expect($result)->toHaveKey('status');
|
||||
expect($result['status'])->toBeBool();
|
||||
|
||||
if (isset($result['binary'])) {
|
||||
expect($result['binary'])->toBeIn(['pg_dump', 'mysqldump']);
|
||||
}
|
||||
});
|
||||
|
||||
test('testConnection on local disk succeeds', function () {
|
||||
// Storage::fake may fail in Docker containers with restricted permissions
|
||||
try {
|
||||
Storage::fake('local');
|
||||
} catch (UnableToCreateDirectory $e) {
|
||||
$this->markTestSkipped('Cannot create fake disk in this environment: '.$e->getMessage());
|
||||
}
|
||||
Config::set('backup.backup.destination.disks', ['local']);
|
||||
|
||||
$result = $this->service->testConnection();
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['message'])->toContain('Successfully');
|
||||
});
|
||||
|
||||
test('parseBytes round-trips through formatBytes for whole units', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '1 KB'))->toBe(1024.0);
|
||||
expect($ref->invoke($this->service, '1 MB'))->toBe(1048576.0);
|
||||
expect($ref->invoke($this->service, '2 GB'))->toBe(2.0 * 1024 * 1024 * 1024);
|
||||
expect($ref->invoke($this->service, '512 B'))->toBe(512.0);
|
||||
});
|
||||
|
||||
test('parseBytes returns raw float when no unit suffix', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '4096'))->toBe(4096.0);
|
||||
});
|
||||
|
||||
test('parseBytes ignores unknown units', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '10 XB'))->toBe(10.0);
|
||||
});
|
||||
|
||||
test('parseBytes handles fractional values', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'parseBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, '1.5 KB'))->toBe(1536.0);
|
||||
});
|
||||
|
||||
test('formatBytes private helper renders KB/MB/GB units', function () {
|
||||
$ref = new ReflectionMethod(BackupManagementService::class, 'formatBytes');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($this->service, 1024))->toBe('1 KB');
|
||||
expect($ref->invoke($this->service, 1024 * 1024))->toBe('1 MB');
|
||||
expect($ref->invoke($this->service, 1024 * 1024 * 1024))->toBe('1 GB');
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\SystemSettingRevision;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
$reset = function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
};
|
||||
$reset();
|
||||
Cache::flush();
|
||||
$this->service = app(SystemConfigService::class);
|
||||
});
|
||||
|
||||
test('definitions returns non-empty array with expected meta keys', function () {
|
||||
$defs = SystemConfigService::definitions();
|
||||
|
||||
expect($defs)->toBeArray()->not->toBeEmpty();
|
||||
expect($defs)->toHaveKey('app_name');
|
||||
|
||||
foreach ($defs as $key => $meta) {
|
||||
expect($meta)->toHaveKeys(['type', 'group', 'is_public']);
|
||||
}
|
||||
});
|
||||
|
||||
test('all returns definition defaults when DB is empty', function () {
|
||||
$all = $this->service->all();
|
||||
|
||||
expect($all['app_name'])->toBe('Laravel');
|
||||
expect($all['regional_timezone'])->toBe('Asia/Jakarta');
|
||||
});
|
||||
|
||||
test('all returns DB values when row exists', function () {
|
||||
SystemSetting::create([
|
||||
'key' => 'app_name',
|
||||
'value' => 'CustomApp',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$all = $this->service->all(forceRefresh: true);
|
||||
|
||||
expect($all['app_name'])->toBe('CustomApp');
|
||||
});
|
||||
|
||||
test('get returns default for unknown key', function () {
|
||||
expect($this->service->get('nonexistent_key', 'fallback'))->toBe('fallback');
|
||||
});
|
||||
|
||||
test('get returns value for known key', function () {
|
||||
expect($this->service->get('app_name'))->toBe('Laravel');
|
||||
});
|
||||
|
||||
test('getPublicSettings only includes is_public=true keys', function () {
|
||||
$public = $this->service->getPublicSettings();
|
||||
|
||||
expect($public)->toHaveKey('app_name');
|
||||
|
||||
foreach ($public as $key => $value) {
|
||||
$meta = SystemConfigService::definitions()[$key];
|
||||
expect($meta['is_public'])->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('grouped returns settings keyed by group', function () {
|
||||
$grouped = $this->service->grouped();
|
||||
|
||||
expect($grouped)->toHaveKey('branding');
|
||||
expect($grouped['branding'])->toHaveKey('app_name');
|
||||
});
|
||||
|
||||
test('update creates new setting row', function () {
|
||||
$this->service->update(['app_name' => 'Updated']);
|
||||
|
||||
$this->assertDatabaseHas('system_settings', [
|
||||
'key' => 'app_name',
|
||||
'value' => 'Updated',
|
||||
]);
|
||||
});
|
||||
|
||||
test('update overwrites existing setting row', function () {
|
||||
SystemSetting::create([
|
||||
'key' => 'app_name',
|
||||
'value' => 'Old',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$this->service->update(['app_name' => 'New']);
|
||||
|
||||
expect(SystemSetting::where('key', 'app_name')->count())->toBe(1);
|
||||
expect(SystemSetting::where('key', 'app_name')->first()->value)->toBe('New');
|
||||
});
|
||||
|
||||
test('update writes a revision row', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->service->update(['app_name' => 'Revisioned'], actorId: $user->id);
|
||||
|
||||
$rev = SystemSettingRevision::where('key', 'app_name')->first();
|
||||
|
||||
expect($rev)->not->toBeNull();
|
||||
expect($rev->changed_by)->toBe($user->id);
|
||||
expect($rev->new_value)->toContain('Revisioned');
|
||||
});
|
||||
|
||||
test('update does not write revision when value unchanged', function () {
|
||||
SystemSetting::create([
|
||||
'key' => 'app_name',
|
||||
'value' => 'Same',
|
||||
'type' => 'string',
|
||||
'group' => 'branding',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$this->service->update(['app_name' => 'Same']);
|
||||
|
||||
expect(SystemSettingRevision::where('key', 'app_name')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('update serializes bool values to 1 or 0', function () {
|
||||
$this->service->update(['enable_landing_page' => false]);
|
||||
|
||||
$row = SystemSetting::where('key', 'enable_landing_page')->first();
|
||||
expect($row->value)->toBe('0');
|
||||
|
||||
$this->service->update(['enable_landing_page' => true]);
|
||||
$row->refresh();
|
||||
expect($row->value)->toBe('1');
|
||||
});
|
||||
|
||||
test('update clears the cache after writing', function () {
|
||||
Cache::put('system_settings.all', ['app_name' => 'StaleCached'], 60);
|
||||
|
||||
$this->service->update(['app_name' => 'Fresh']);
|
||||
|
||||
expect(Cache::has('system_settings.all'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('update normalizes bool input from string', function () {
|
||||
$this->service->update(['enable_landing_page' => 'false']);
|
||||
|
||||
$row = SystemSetting::where('key', 'enable_landing_page')->first();
|
||||
expect($row->value)->toBe('0');
|
||||
});
|
||||
|
||||
test('update records request IP and user agent in revision', function () {
|
||||
$request = Request::create('/system-settings', 'POST', server: [
|
||||
'REMOTE_ADDR' => '203.0.113.7',
|
||||
'HTTP_USER_AGENT' => 'pest-test-agent',
|
||||
]);
|
||||
|
||||
$this->service->update(['app_name' => 'Tracked'], request: $request);
|
||||
|
||||
$rev = SystemSettingRevision::where('key', 'app_name')->first();
|
||||
expect($rev->changed_ip)->toBe('203.0.113.7');
|
||||
expect($rev->changed_agent)->toBe('pest-test-agent');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\System;
|
||||
|
||||
use App\Models\AiHealingLog;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiCircuitBreakerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(\Database\Seeders\RoleAndPermissionSeeder::class);
|
||||
}
|
||||
|
||||
public function test_circuit_breaker_blocks_excessive_healing_attempts()
|
||||
{
|
||||
// 1. Set limit to 2 fixes per hour
|
||||
$config = app(SystemConfigService::class);
|
||||
$config->update(['ai_healing_max_attempts_per_hour' => 2]);
|
||||
|
||||
// 2. Create 2 recent "resolved" logs
|
||||
AiHealingLog::factory()->count(2)->create([
|
||||
'status' => 'resolved',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
// 3. Verify count
|
||||
$this->assertEquals(2, AiHealingLog::where('created_at', '>', now()->subHour())->count());
|
||||
|
||||
// 4. Simulate a new exception (we need to trigger the logic in bootstrap/app.php or simulate it)
|
||||
// Since we can't easily trigger bootstrap/app.php in a feature test without throwing a real exception,
|
||||
// we can test the logic directly or via a mock.
|
||||
|
||||
// Let's test if the circuit breaker logic would block.
|
||||
$maxPerHour = (int) $config->get('ai_healing_max_attempts_per_hour', 5);
|
||||
$recentCount = AiHealingLog::where('created_at', '>', now()->subHour())
|
||||
->whereIn('status', ['resolved', 'pending', 'diagnosing'])
|
||||
->count();
|
||||
|
||||
$this->assertTrue($recentCount >= $maxPerHour, "Circuit breaker should be tripped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
function makeEditorUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage global settings', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
// ── Access control ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot upload editor images', function () {
|
||||
$this->postJson('/editor/upload')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('user without manage global settings cannot upload', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
// ── Missing file ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('upload without file returns 400', function () {
|
||||
$user = makeEditorUser();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload')
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('error.message', 'No file uploaded.');
|
||||
});
|
||||
|
||||
// ── Type validation ────────────────────────────────────────────────────────────
|
||||
|
||||
test('upload rejects non-image file', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->create('shell.php', 100, 'application/x-php');
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload', ['upload' => $file])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload rejects svg (not in allowed mimes)', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->create('vector.svg', 50, 'image/svg+xml');
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload', ['upload' => $file])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload accepts jpeg image', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->assertJsonPath('uploaded', 1)
|
||||
->assertJsonStructure(['url', 'fileName']);
|
||||
});
|
||||
|
||||
test('upload accepts png image', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('icon.png', 100, 100);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->assertJsonPath('uploaded', 1);
|
||||
});
|
||||
|
||||
test('upload accepts webp image', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('modern.webp', 400, 300);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->assertJsonPath('uploaded', 1);
|
||||
});
|
||||
|
||||
// ── Size limit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('upload rejects image exceeding 5 MB', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->create('big.jpg', 6000, 'image/jpeg'); // 6 MB
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload', ['upload' => $file])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── Response structure ─────────────────────────────────────────────────────────
|
||||
|
||||
test('successful upload response has uploaded, fileName, url keys', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('test.jpg');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
expect($response)->toHaveKeys(['uploaded', 'fileName', 'url']);
|
||||
expect($response['uploaded'])->toBe(1);
|
||||
expect($response['url'])->toStartWith('/storage/');
|
||||
});
|
||||
|
||||
// ── File is actually stored ────────────────────────────────────────────────────
|
||||
|
||||
test('uploaded file is persisted to the public disk under editor/', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('stored.jpg');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->json();
|
||||
|
||||
// Strip /storage/ prefix to get the relative path on the public disk
|
||||
$relativePath = substr((string) $response['url'], strlen('/storage/'));
|
||||
Storage::disk('public')->assertExists($relativePath);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
function makeNotificationAdmin(): User
|
||||
{
|
||||
$role = Role::firstOrCreate(['name' => 'Developer', 'guard_name' => 'web']);
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
|
||||
foreach (['view notification center', 'manage notification center'] as $name) {
|
||||
$perm = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeNotificationViewer(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$perm = Permission::firstOrCreate(['name' => 'view notification center', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeNotificationForUser(User $user): Notification
|
||||
{
|
||||
$role = Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$notification = Notification::create([
|
||||
'title' => 'Test Notice',
|
||||
'message' => 'Hello',
|
||||
'recipient' => 'all',
|
||||
'type' => 'info',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Attach pivot row so the user sees it
|
||||
\DB::table('system_notification_user')->insert([
|
||||
'notification_id' => $notification->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => null,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
// ── Access control ────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot view notification center', function () {
|
||||
$this->get('/notification-center')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission is forbidden from notification center', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/notification-center')->assertForbidden();
|
||||
});
|
||||
|
||||
test('user with view permission can access notification center', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($user)->get('/notification-center')->assertOk();
|
||||
});
|
||||
|
||||
// ── Store (broadcast) ─────────────────────────────────────────────────────────
|
||||
|
||||
test('viewer without manage permission cannot broadcast notifications', function () {
|
||||
$viewer = makeNotificationViewer();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->postJson('/notification-center', [
|
||||
'title' => 'Test',
|
||||
'message' => 'Msg',
|
||||
'recipient' => 'User',
|
||||
'type' => 'info',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin can broadcast a notification', function () {
|
||||
$admin = makeNotificationAdmin();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/notification-center', [
|
||||
'title' => 'Important Update',
|
||||
'message' => 'Please read.',
|
||||
'recipient' => 'User',
|
||||
'type' => 'info',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
expect(Notification::where('title', 'Important Update')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('store rejects unknown notification type', function () {
|
||||
$admin = makeNotificationAdmin();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/notification-center', [
|
||||
'title' => 'Bad Type',
|
||||
'message' => 'Msg',
|
||||
'recipient' => 'User',
|
||||
'type' => 'xss-alert',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store requires title and message', function () {
|
||||
$admin = makeNotificationAdmin();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/notification-center', ['type' => 'info'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── Mark as read ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('user can mark a notification as read', function () {
|
||||
$user = makeNotificationViewer();
|
||||
$notification = makeNotificationForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patchJson(route('notification-center.read', $notification->id))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
test('guest cannot mark notifications as read', function () {
|
||||
$notification = Notification::create([
|
||||
'title' => 'X', 'message' => 'Y', 'recipient' => 'all', 'type' => 'info',
|
||||
]);
|
||||
|
||||
// JSON requests return 401 Unauthorized (not a redirect) when unauthenticated
|
||||
$this->patchJson(route('notification-center.read', $notification->id))
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
// ── Mark all as read ──────────────────────────────────────────────────────────
|
||||
|
||||
test('user can mark all notifications as read', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patchJson(route('notification-center.read-all'))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
// ── Personal delete ───────────────────────────────────────────────────────────
|
||||
|
||||
test('user can delete (hide) a notification from their view', function () {
|
||||
$user = makeNotificationViewer();
|
||||
$notification = makeNotificationForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson(route('notification-center.destroy', $notification->id))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
// ── Feature flag ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('feature disabled blocks regular viewers but allows manage-global-settings users', function () {
|
||||
app(SystemConfigService::class)->update(['feature_notification_center' => false]);
|
||||
|
||||
$viewer = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get('/notification-center')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('feature flag disabled still allows users with manage global settings', function () {
|
||||
app(SystemConfigService::class)->update(['feature_notification_center' => false]);
|
||||
|
||||
$admin = makeNotificationViewer();
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage global settings', 'guard_name' => 'web']);
|
||||
$admin->givePermissionTo($perm);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/notification-center')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
// ── Recent notifications API ──────────────────────────────────────────────────
|
||||
|
||||
test('recent notifications endpoint returns json with unread count', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/notification-center/api/recent')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['success', 'unread_count', 'notifications', 'has_more']);
|
||||
});
|
||||
|
||||
test('recent notifications content is escaped', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$notification = Notification::create([
|
||||
'title' => '<script>alert(1)</script>',
|
||||
'message' => '<b>bold</b>',
|
||||
'recipient' => 'all',
|
||||
'type' => 'info',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
\DB::table('system_notification_user')->insert([
|
||||
'notification_id' => $notification->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => null,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/notification-center/api/recent')
|
||||
->json();
|
||||
|
||||
$titles = collect($response['notifications'])->pluck('title')->all();
|
||||
// Title must be HTML-entity-escaped, not raw script tag
|
||||
foreach ($titles as $t) {
|
||||
expect($t)->not->toContain('<script>');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
|
||||
function makeSessionManager(): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
foreach (['view active sessions', 'manage active sessions'] as $name) {
|
||||
$perm = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeSessionViewer(): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$perm = Permission::firstOrCreate(['name' => 'view active sessions', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ── Access control ────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot access session manager', function () {
|
||||
$this->get('/session-manager')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission is forbidden from session manager', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/session-manager')->assertForbidden();
|
||||
});
|
||||
|
||||
test('user with view permission can access session manager', function () {
|
||||
$user = makeSessionViewer();
|
||||
|
||||
$this->actingAs($user)->get('/session-manager')->assertOk();
|
||||
});
|
||||
|
||||
test('guest cannot access session stats endpoint', function () {
|
||||
// JSON requests return 401 Unauthorized (not a redirect) for unauthenticated requests
|
||||
$this->getJson('/session-manager/stats')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('user with view permission can access session stats', function () {
|
||||
$user = makeSessionViewer();
|
||||
|
||||
$this->actingAs($user)->getJson('/session-manager/stats')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['total', 'active']);
|
||||
});
|
||||
|
||||
// ── Stats structure ────────────────────────────────────────────────────────────
|
||||
|
||||
test('stats endpoint returns valid json with total key', function () {
|
||||
$user = makeSessionViewer();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/session-manager/stats')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['total', 'active']);
|
||||
});
|
||||
|
||||
// ── Terminate session ─────────────────────────────────────────────────────────
|
||||
|
||||
test('user without manage permission cannot terminate a session', function () {
|
||||
$viewer = makeSessionViewer();
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->deleteJson('/session-manager/some-session-id')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('manager cannot terminate their own current session', function () {
|
||||
// The controller's destroy() checks: if ($id === session()->getId()) → 403.
|
||||
// We invoke it directly, bypassing HTTP, so the session ID is stable.
|
||||
$manager = makeSessionManager();
|
||||
$this->actingAs($manager);
|
||||
|
||||
$controller = app(\App\Http\Controllers\SystemSettings\SessionManagerController::class);
|
||||
$currentId = session()->getId();
|
||||
|
||||
$response = $controller->destroy(request(), $currentId);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
expect(json_decode($response->getContent(), true)['success'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('manager can terminate a different session id', function () {
|
||||
// With array/redis driver the controller returns success when no row is found to delete.
|
||||
$manager = makeSessionManager();
|
||||
|
||||
$this->actingAs($manager)
|
||||
->deleteJson('/session-manager/some-other-session-id')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
test('guest cannot terminate sessions', function () {
|
||||
// JSON requests return 401 (not a redirect) for unauthenticated requests
|
||||
$this->deleteJson('/session-manager/any-id')->assertUnauthorized();
|
||||
});
|
||||
Reference in New Issue
Block a user