feat: add routes, lang, tests, stubs, docs, and docker configurations

This commit is contained in:
2026-05-21 16:05:16 +07:00
parent fad70d096b
commit 28a06315b8
3385 changed files with 177070 additions and 0 deletions
@@ -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();
});
+199
View File
@@ -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);
});
+101
View File
@@ -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);
});
+58
View File
@@ -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();
});
+27
View File
@@ -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']);
}
});
+31
View File
@@ -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);
});
+41
View File
@@ -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);
});
+60
View File
@@ -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;
});
});
+40
View File
@@ -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');
});
+25
View File
@@ -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));
});
+131
View File
@@ -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);
});
+156
View File
@@ -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();
});
+136
View File
@@ -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();
});
+51
View File
@@ -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);
});
+7
View File
@@ -0,0 +1,7 @@
<?php
it('returns a successful response', function () {
$response = $this->get('/');
$response->assertStatus(200);
});
+58
View File
@@ -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();
});
+102
View File
@@ -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();
});
+34
View File
@@ -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();
});
+180
View File
@@ -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);
});
+86
View File
@@ -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());
});
+78
View File
@@ -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");
}
}
+137
View File
@@ -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>');
}
});
+106
View File
@@ -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();
});