Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
|
||||
trait CreatesApplication
|
||||
{
|
||||
/**
|
||||
* Creates the application.
|
||||
*
|
||||
* @return \Illuminate\Foundation\Application
|
||||
*/
|
||||
public function createApplication()
|
||||
{
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\Profiles\Create;
|
||||
use App\Models\Admin;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\User;
|
||||
use App\Traits\ProfilePermissionTrait;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin profiles had two role systems: the legacy role Admin\{id}\admin (all permissions at creation time, never
|
||||
* synced) and the roles from admin_roles (administrator, content-manager, ...). Some checks only accepted the
|
||||
* legacy role and refused admins whose admin_roles role allows the action. Now every check uses the gate, new
|
||||
* admin profiles get admin_settings.default_admin_role, and admin-roles:migrate replaces the legacy roles.
|
||||
*/
|
||||
class AdminRolesTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Keep the permission cache out of the Redis store the dev site shares; a cached set from an earlier
|
||||
// (rolled back) test would hide the permissions created below
|
||||
config(['permission.cache.store' => 'array']);
|
||||
app(PermissionRegistrar::class)->initializeCache();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
// The test database has the admin_roles definitions but no permissions
|
||||
foreach (AdminRole::all() as $definition) {
|
||||
foreach ($definition->permissions ?? [] as $name) {
|
||||
Permission::findOrCreate($name, 'web');
|
||||
}
|
||||
}
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
private function actAsAdminProfile(User $user, Admin $admin): void
|
||||
{
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id, 'active_guard' => 'admin']);
|
||||
}
|
||||
|
||||
private function checks(): object
|
||||
{
|
||||
return new class {
|
||||
use ProfilePermissionTrait;
|
||||
|
||||
public function profiles(): bool { return (bool) $this->getCanManageProfiles(); }
|
||||
public function accounts(): bool { return (bool) $this->getCanManageAccounts(); }
|
||||
};
|
||||
}
|
||||
|
||||
public function test_an_administrator_without_the_legacy_role_may_manage_profiles_and_accounts(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
AdminRole::where('name', 'administrator')->firstOrFail()->assignScopedTo($user, $admin->id);
|
||||
$this->actAsAdminProfile($user, $admin);
|
||||
|
||||
$this->assertTrue($this->checks()->profiles());
|
||||
$this->assertTrue($this->checks()->accounts());
|
||||
}
|
||||
|
||||
public function test_a_role_without_the_permission_is_refused(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$definition = AdminRole::where('name', 'content-editor')->firstOrFail();
|
||||
$this->assertNotContains('manage profiles', $definition->permissions);
|
||||
$definition->assignScopedTo($user, $admin->id);
|
||||
$this->actAsAdminProfile($user, $admin);
|
||||
|
||||
$this->assertFalse($this->checks()->profiles());
|
||||
$this->assertFalse($this->checks()->accounts());
|
||||
}
|
||||
|
||||
public function test_a_role_on_another_admin_profile_does_not_count(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
[$mine, $other] = Admin::factory()->count(2)->create();
|
||||
AdminRole::where('name', 'administrator')->firstOrFail()->assignScopedTo($user, $other->id);
|
||||
$this->actAsAdminProfile($user, $mine);
|
||||
|
||||
$this->assertFalse($this->checks()->profiles());
|
||||
}
|
||||
|
||||
public function test_the_global_super_admin_role_grants_nothing_without_a_scoped_role(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole(Role::findOrCreate('super-admin', 'web'));
|
||||
$admin = Admin::factory()->create();
|
||||
$this->actAsAdminProfile($user, $admin);
|
||||
|
||||
$this->assertFalse($this->checks()->profiles());
|
||||
$this->assertFalse($user->can('manage admins'));
|
||||
}
|
||||
|
||||
public function test_a_new_admin_profile_gives_the_linked_user_the_default_role_only(): void
|
||||
{
|
||||
Mail::fake();
|
||||
config([config('app.timebank_config') . '.admin_settings.default_admin_role' => 'site-editor']);
|
||||
$user = User::factory()->create();
|
||||
$data = Admin::factory()->raw(['password' => 'secret-password', 'linkUser' => $user->id]);
|
||||
|
||||
$create = new \ReflectionMethod(Create::class, 'createAdminProfile');
|
||||
$admin = $create->invoke(app(Create::class), $data);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertTrue($user->hasRole("Admin\\{$admin->id}\\site-editor"));
|
||||
$this->assertFalse($user->hasRole("Admin\\{$admin->id}\\admin"), 'no legacy role');
|
||||
$this->assertFalse($user->hasRole('admin'), 'no global admin role');
|
||||
$this->assertEqualsCanonicalizing(
|
||||
AdminRole::where('name', 'site-editor')->first()->permissions,
|
||||
Role::findByName("Admin\\{$admin->id}\\site-editor", 'web')->permissions->pluck('name')->all()
|
||||
);
|
||||
}
|
||||
|
||||
private function legacyRole(Admin $admin, array $holders): Role
|
||||
{
|
||||
$role = Role::create(['name' => "Admin\\{$admin->id}\\admin", 'guard_name' => 'web']);
|
||||
$role->syncPermissions(Permission::all());
|
||||
foreach ($holders as $holder) {
|
||||
$holder->assignRole($role);
|
||||
}
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
return $role;
|
||||
}
|
||||
|
||||
public function test_the_migration_gives_holders_the_default_role_then_removes_the_legacy_role(): void
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
[$onlyLegacy, $both] = User::factory()->count(2)->create();
|
||||
$administrator = AdminRole::where('name', 'administrator')->firstOrFail();
|
||||
$administrator->assignScopedTo($both, $admin->id);
|
||||
$this->legacyRole($admin, [$onlyLegacy, $both]);
|
||||
|
||||
$this->artisan('admin-roles:migrate')->assertExitCode(0);
|
||||
|
||||
$target = "Admin\\{$admin->id}\\administrator";
|
||||
$this->assertTrue($onlyLegacy->fresh()->hasRole($target), 'the holder with only the legacy role keeps access');
|
||||
$this->assertTrue($both->fresh()->hasRole($target));
|
||||
$this->assertNull(Role::where('name', "Admin\\{$admin->id}\\admin")->first(), 'legacy role removed');
|
||||
$this->assertEqualsCanonicalizing($administrator->permissions, Role::findByName($target, 'web')->permissions->pluck('name')->all());
|
||||
|
||||
// A second run changes nothing
|
||||
$this->artisan('admin-roles:migrate')->expectsOutputToContain('nothing to do')->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_a_dry_run_changes_nothing(): void
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$this->legacyRole($admin, [$user]);
|
||||
|
||||
$this->artisan('admin-roles:migrate', ['--dry-run' => true])
|
||||
->expectsOutputToContain("user #{$user->id} gets Admin\\{$admin->id}\\administrator")
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNotNull(Role::where('name', "Admin\\{$admin->id}\\admin")->first());
|
||||
$this->assertFalse($user->fresh()->hasRole("Admin\\{$admin->id}\\administrator"));
|
||||
}
|
||||
|
||||
public function test_without_a_definition_for_the_default_role_nothing_changes(): void
|
||||
{
|
||||
config([config('app.timebank_config') . '.admin_settings.default_admin_role' => 'no-such-role']);
|
||||
$admin = Admin::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$this->legacyRole($admin, [$user]);
|
||||
|
||||
$this->artisan('admin-roles:migrate')->expectsOutputToContain("'no-such-role'")->assertExitCode(1);
|
||||
|
||||
$this->assertNotNull(Role::where('name', "Admin\\{$admin->id}\\admin")->first());
|
||||
$this->assertTrue($user->fresh()->hasRole("Admin\\{$admin->id}\\admin"));
|
||||
}
|
||||
|
||||
public function test_upgrade_verify_warns_about_legacy_roles(): void
|
||||
{
|
||||
$this->legacyRole(Admin::factory()->create(), []);
|
||||
|
||||
$this->artisan('upgrade:verify', ['--only' => 'permissions'])
|
||||
->expectsOutputToContain('legacy roles')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_the_settings_form_opens_for_a_role_with_admin_settings_but_without_manage_admins(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
// The migration's definitions predate the admin capabilities that database:update adds
|
||||
$definition = AdminRole::where('name', 'content-editor')->firstOrFail();
|
||||
$this->assertNotContains('manage admins', $definition->permissions);
|
||||
Permission::findOrCreate('admin settings', 'web');
|
||||
$definition->update(['permissions' => array_values(array_unique([...$definition->permissions, 'admin settings']))]);
|
||||
$definition->assignScopedTo($user, $admin->id);
|
||||
$this->actAsAdminProfile($user, $admin);
|
||||
|
||||
\Livewire\Livewire::test(\App\Http\Livewire\Profile\UpdateSettingsForm::class)->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_the_flat_admin_role_migration_only_grants_on_linked_admin_profiles(): void
|
||||
{
|
||||
[$mine, $other] = Admin::factory()->count(2)->create();
|
||||
[$linked, $unlinked] = User::factory()->count(2)->create();
|
||||
$mine->users()->attach($linked->id);
|
||||
$flat = Role::findOrCreate('admin', 'web');
|
||||
$linked->assignRole($flat);
|
||||
$unlinked->assignRole($flat);
|
||||
|
||||
$command = app(\App\Console\Commands\DatabaseUpdate::class);
|
||||
$command->setLaravel(app());
|
||||
$output = new \Symfony\Component\Console\Output\BufferedOutput();
|
||||
$command->setOutput(new \Illuminate\Console\OutputStyle(new \Symfony\Component\Console\Input\ArrayInput([]), $output));
|
||||
(new \ReflectionMethod($command, 'migrateFlatAdminRoleToScoped'))->invoke($command);
|
||||
|
||||
$scoped = fn (User $user) => $user->fresh()->roles->pluck('name')->filter(fn ($n) => str_starts_with($n, 'Admin\\'))->values()->all();
|
||||
$this->assertSame(["Admin\\{$mine->id}\\administrator"], $scoped($linked), 'only the linked admin profile');
|
||||
$this->assertSame([], $scoped($unlinked), 'no admin profile link: no role on any admin profile');
|
||||
$this->assertStringContainsString("id:{$unlinked->id}) has the flat admin role but no admin profile link", $output->fetch());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Laravel\Jetstream\Http\Livewire\ApiTokenManager;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiTokenPermissionsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_token_permissions_can_be_updated()
|
||||
{
|
||||
if (! Features::hasApiFeatures()) {
|
||||
return $this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
|
||||
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
'token' => Str::random(40),
|
||||
'abilities' => ['create', 'read'],
|
||||
]);
|
||||
|
||||
Livewire::test(ApiTokenManager::class)
|
||||
->set(['managingPermissionsFor' => $token])
|
||||
->set(['updateApiTokenForm' => [
|
||||
'permissions' => [
|
||||
'delete',
|
||||
'missing-permission',
|
||||
],
|
||||
]])
|
||||
->call('updateApiToken');
|
||||
|
||||
$this->assertTrue($user->fresh()->tokens->first()->can('delete'));
|
||||
$this->assertFalse($user->fresh()->tokens->first()->can('read'));
|
||||
$this->assertFalse($user->fresh()->tokens->first()->can('missing-permission'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered()
|
||||
{
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function test_user_redirects_to_main_page_in_preferred_locale_on_login()
|
||||
{
|
||||
$preferredLocale = 'en'; // Example user preference
|
||||
$user = User::factory()->create([
|
||||
'password' => bcrypt('password'),
|
||||
'lang_preference' => $preferredLocale,
|
||||
]);
|
||||
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
$response = $this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
|
||||
$expectedRedirectUrl = LaravelLocalization::getURLFromRouteNameTranslated(
|
||||
$preferredLocale,
|
||||
'routes.main'
|
||||
);
|
||||
$response->assertRedirect($expectedRedirectUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BrowserSessionsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_auth_logout_other_devices_invalidates_other_sessions()
|
||||
{
|
||||
$this->markTestSkipped(
|
||||
'The "Logout Other Browser Sessions" functionality currently requires manual testing. ' .
|
||||
'Automated test faced persistent issues with session handling and password validation in the test environment.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml runs with APP_ENV=local, which keeps CSRF checks on
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
}
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered()
|
||||
{
|
||||
$user = User::factory()->withPersonalTeam()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/user/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_password_is_not_confirmed_with_invalid_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\UserPasswordResetNotification as ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml runs with APP_ENV=local, which keeps CSRF checks on
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered()
|
||||
{
|
||||
if (! Features::enabled(Features::resetPasswords())) {
|
||||
return $this->markTestSkipped('Password updates are not enabled.');
|
||||
}
|
||||
|
||||
$response = $this->get(route('password.request'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested()
|
||||
{
|
||||
if (! Features::enabled(Features::resetPasswords())) {
|
||||
return $this->markTestSkipped('Password updates are not enabled.');
|
||||
}
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post(route('password.email'), [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered()
|
||||
{
|
||||
if (! Features::enabled(Features::resetPasswords())) {
|
||||
return $this->markTestSkipped('Password updates are not enabled.');
|
||||
}
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post(route('password.email'), [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get(route('password.reset', ['token' => $notification->token]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token()
|
||||
{
|
||||
if (! Features::enabled(Features::resetPasswords())) {
|
||||
return $this->markTestSkipped('Password updates are not enabled.');
|
||||
}
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post(route('password.email'), [
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post(route('password.update'), [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'New-password-2026!',
|
||||
'password_confirmation' => 'New-password-2026!',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Jetstream\Http\Livewire\UpdatePasswordForm;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UpdatePasswordTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_password_can_be_updated()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
Livewire::test(UpdatePasswordForm::class)
|
||||
->set('state', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
])
|
||||
->call('updatePassword');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->fresh()->password));
|
||||
}
|
||||
|
||||
public function test_current_password_must_be_correct()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
Livewire::test(UpdatePasswordForm::class)
|
||||
->set('state', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
])
|
||||
->call('updatePassword')
|
||||
->assertHasErrors(['current_password']);
|
||||
|
||||
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
||||
}
|
||||
|
||||
public function test_new_passwords_must_match()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
Livewire::test(UpdatePasswordForm::class)
|
||||
->set('state', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'wrong-password',
|
||||
])
|
||||
->call('updatePassword')
|
||||
->assertHasErrors(['password']);
|
||||
|
||||
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Console\Commands\ProcessInactiveProfiles;
|
||||
use App\Http\Middleware\CheckProfileInactivity;
|
||||
use App\Http\Middleware\TrackUserPresence;
|
||||
use App\Mail\InactiveProfileWarning1Mail;
|
||||
use App\Models\User;
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Durations that must stay positive whole numbers after Carbon 3.
|
||||
*
|
||||
* Carbon 3 made diffIn*() signed and fractional: now()->diffInMinutes($past) became negative. Unchanged,
|
||||
* that silently disabled the inactivity logout, presence updates and the processing of inactive profiles,
|
||||
* and put negative periods in the warning mails. The code now passes absolute = true and casts to int,
|
||||
* which is what Carbon 2 returned.
|
||||
*/
|
||||
class CarbonDurationsTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Tests share Redis with the dev site: keep presence keys out of it
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
private function runInactivityMiddleware(Carbon $lastActivity): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Auth::guard('web')->login($user);
|
||||
Session::put(['activeProfileType' => User::class, 'activeProfileId' => $user->id, 'last_activity' => $lastActivity]);
|
||||
|
||||
$request = Request::create('/');
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
|
||||
app(CheckProfileInactivity::class)->handle($request, fn () => response('next'));
|
||||
}
|
||||
|
||||
public function test_an_idle_session_beyond_the_session_lifetime_is_logged_out(): void
|
||||
{
|
||||
$this->runInactivityMiddleware(now()->subMinutes(config('session.lifetime', 120) + 5));
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->guest());
|
||||
}
|
||||
|
||||
public function test_a_recently_active_session_stays_logged_in(): void
|
||||
{
|
||||
$this->runInactivityMiddleware(now()->subMinutes(2));
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
public function test_presence_is_refreshed_once_the_update_interval_has_passed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Auth::guard('web')->login($user);
|
||||
Cache::put("presence_last_update_web_{$user->id}", now()->subMinutes(10), 300);
|
||||
|
||||
$presence = $this->mock(PresenceService::class);
|
||||
$presence->shouldReceive('updatePresence')->once();
|
||||
|
||||
$request = Request::create('/');
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
app(TrackUserPresence::class)->handle($request, fn () => response('next'));
|
||||
}
|
||||
|
||||
public function test_a_long_inactive_profile_is_deleted_with_a_positive_age(): void
|
||||
{
|
||||
$command = new class extends ProcessInactiveProfiles {
|
||||
public array $calls = [];
|
||||
|
||||
protected function deleteProfile($profile, $profileType, $secondsSinceInactive)
|
||||
{
|
||||
$this->calls[] = ['delete', $secondsSinceInactive];
|
||||
return 'deleted';
|
||||
}
|
||||
|
||||
protected function sendWarning($profile, $profileType, $warningLevel, $secondsRemaining, $secondsSinceInactive)
|
||||
{
|
||||
$this->calls[] = [$warningLevel, $secondsSinceInactive];
|
||||
return 'warning';
|
||||
}
|
||||
};
|
||||
|
||||
$days = (int) timebank_config('delete_profile.days_after_inactive.run_delete');
|
||||
$profile = (object) ['inactive_at' => now()->subDays($days + 1), 'last_inactive_warning' => 'final'];
|
||||
|
||||
$process = new \ReflectionMethod($command, 'processProfile');
|
||||
$process->invoke($command, $profile, 'User');
|
||||
|
||||
$this->assertSame('delete', $command->calls[0][0] ?? null);
|
||||
$this->assertIsInt($command->calls[0][1]);
|
||||
$this->assertGreaterThan(0, $command->calls[0][1]);
|
||||
}
|
||||
|
||||
public function test_the_warning_mail_shows_a_positive_period_since_the_last_login(): void
|
||||
{
|
||||
$mail = (new \ReflectionClass(InactiveProfileWarning1Mail::class))->newInstanceWithoutConstructor();
|
||||
$format = new \ReflectionMethod($mail, 'formatPeriod');
|
||||
|
||||
$this->assertSame(trans_choice('days_not_logged_in', 3, ['count' => 3]), $format->invoke($mail, now()->subDays(3)));
|
||||
$this->assertSame(trans_choice('weeks_not_logged_in', 2, ['count' => 2]), $format->invoke($mail, now()->subDays(14)));
|
||||
}
|
||||
|
||||
public function test_timestamps_are_shown_in_the_app_timezone(): void
|
||||
{
|
||||
$timestamp = strtotime('2026-10-15 23:30:00');
|
||||
|
||||
$this->assertSame('2026-10-15 23:30', Carbon::createFromTimestamp($timestamp, config('app.timezone'))->format('Y-m-d H:i'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Console\Commands\ConfigMerge;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* config:merge copies new keys' SOURCE from the .example file into the active
|
||||
* config and leaves the rest of the file as it was.
|
||||
*
|
||||
* It used to write the whole file back from evaluated arrays, which froze
|
||||
* every env() call into a fixed value and dropped all comments. On production
|
||||
* health_monitor.healthcheck_url became null that way, and the URL set in
|
||||
* .env afterwards was never read (2026-10-07).
|
||||
*/
|
||||
class ConfigMergeCommandTest extends TestCase
|
||||
{
|
||||
private string $path;
|
||||
private string $examplePath;
|
||||
private string $backupDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->path = base_path('config/test-config.php');
|
||||
$this->examplePath = $this->path . '.example';
|
||||
$this->backupDir = storage_path('config-backups');
|
||||
|
||||
// The real command only accepts the platform's own config files.
|
||||
$this->app->make(Kernel::class)->registerCommand(new class extends ConfigMerge {
|
||||
protected $mergeableConfigs = ['test-config' => 'config/test-config.php'];
|
||||
});
|
||||
|
||||
$this->cleanup();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanup();
|
||||
putenv('CONFIG_MERGE_TEST_URL');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function cleanup(): void
|
||||
{
|
||||
File::delete([$this->path, $this->examplePath]);
|
||||
|
||||
foreach (File::glob("{$this->backupDir}/test-config.php.backup.*") as $backup) {
|
||||
File::delete($backup);
|
||||
}
|
||||
}
|
||||
|
||||
private function write(string $current, string $example): void
|
||||
{
|
||||
File::put($this->path, $current);
|
||||
File::put($this->examplePath, $example);
|
||||
}
|
||||
|
||||
private function merge(array $options = []): \Illuminate\Testing\PendingCommand
|
||||
{
|
||||
return $this->artisan('config:merge', ['file' => 'test-config', '--force' => true] + $options);
|
||||
}
|
||||
|
||||
private function load(): array
|
||||
{
|
||||
return include $this->path;
|
||||
}
|
||||
|
||||
public function test_a_new_key_keeps_its_env_call_and_comment(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'live',
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'example',
|
||||
|
||||
// Ping URL for healthchecks.io.
|
||||
'monitor' => [
|
||||
'url' => env('CONFIG_MERGE_TEST_URL'),
|
||||
'limit' => 90,
|
||||
],
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$source = File::get($this->path);
|
||||
|
||||
$this->assertStringContainsString("'url' => env('CONFIG_MERGE_TEST_URL'),", $source);
|
||||
$this->assertStringContainsString('// Ping URL for healthchecks.io.', $source);
|
||||
|
||||
// And it reads the environment when loaded, not at merge time.
|
||||
putenv('CONFIG_MERGE_TEST_URL=https://hc-ping.test/abc');
|
||||
$this->assertSame('https://hc-ping.test/abc', $this->load()['monitor']['url']);
|
||||
$this->assertSame('live', $this->load()['name']);
|
||||
}
|
||||
|
||||
public function test_the_rest_of_the_file_is_left_exactly_as_it_was(): void
|
||||
{
|
||||
$current = <<<'PHP'
|
||||
<?php
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/*
|
||||
* Platform settings.
|
||||
*/
|
||||
return [
|
||||
// Kept: a comment on an existing key.
|
||||
'mail' => env('CONFIG_MERGE_TEST_MAIL', 'admin@example.test'),
|
||||
'rules' => [
|
||||
'name' => fn ($value) => strlen($value) > 2,
|
||||
],
|
||||
'list' => ['a', 'b'],
|
||||
];
|
||||
PHP;
|
||||
$this->write($current, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'mail' => 'other@example.test',
|
||||
'rules' => [
|
||||
'name' => null,
|
||||
'slug' => 'required',
|
||||
],
|
||||
'list' => ['x', 'y'],
|
||||
'added' => true,
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$merged = File::get($this->path);
|
||||
|
||||
// Everything that was there is still there, untouched...
|
||||
$this->assertStringContainsString("'mail' => env('CONFIG_MERGE_TEST_MAIL', 'admin@example.test'),", $merged);
|
||||
$this->assertStringContainsString('// Kept: a comment on an existing key.', $merged);
|
||||
$this->assertStringContainsString("'name' => fn (\$value) => strlen(\$value) > 2,", $merged);
|
||||
$this->assertStringContainsString("use Illuminate\\Validation\\Rule;", $merged);
|
||||
|
||||
// ...and removing the two added lines gives back the original file.
|
||||
$withoutAdded = preg_replace("/\n[ ]*'slug' => 'required',|\n[ ]*'added' => true,/", '', $merged);
|
||||
$this->assertSame($current, $withoutAdded);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame('required', $config['rules']['slug']);
|
||||
$this->assertTrue($config['added']);
|
||||
$this->assertSame(['a', 'b'], $config['list'], 'Existing values win.');
|
||||
}
|
||||
|
||||
public function test_several_keys_into_one_array_and_awkward_layouts(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'empty' => [],
|
||||
'inline' => ['a' => 1],
|
||||
'no_trailing_comma' => [
|
||||
'a' => 1
|
||||
],
|
||||
'nested' => [
|
||||
'deep' => [
|
||||
'keep' => 'live',
|
||||
],
|
||||
]
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'empty' => ['x' => 1],
|
||||
'inline' => ['a' => 0, 'b' => 2],
|
||||
'no_trailing_comma' => [
|
||||
'a' => 0,
|
||||
'b' => 2,
|
||||
'c' => 3,
|
||||
],
|
||||
'nested' => [
|
||||
'deep' => [
|
||||
'keep' => 'example',
|
||||
'new' => env('CONFIG_MERGE_TEST_URL', 'fallback'),
|
||||
],
|
||||
],
|
||||
'top' => 'level',
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
|
||||
$this->assertSame(['x' => 1], $config['empty']);
|
||||
$this->assertSame(['a' => 1, 'b' => 2], $config['inline']);
|
||||
$this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $config['no_trailing_comma']);
|
||||
$this->assertSame(['keep' => 'live', 'new' => 'fallback'], $config['nested']['deep']);
|
||||
$this->assertSame('level', $config['top']);
|
||||
$this->assertStringContainsString("'new' => env('CONFIG_MERGE_TEST_URL', 'fallback'),", File::get($this->path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A list is the platform's own choice as a whole. Merging it by position gave Uuro (2026-10-18) the
|
||||
* template's footer sections and links, and put the template's 'route' into Uuro's mailto link;
|
||||
* a platform with fewer payment types would have been given the missing ones.
|
||||
*/
|
||||
public function test_an_existing_list_is_kept_as_a_whole(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'langs' => ['en', 'nl'],
|
||||
'payment_types' => ['work'],
|
||||
'footer' => [
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Contact',
|
||||
'links' => [
|
||||
['url' => 'mailto:hello@example.test', 'title' => 'Mail us', 'order' => 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'langs' => ['en', 'nl', 'de'],
|
||||
'payment_types' => ['work', 'gift', 'donation'],
|
||||
'footer' => [
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Who we are',
|
||||
'links' => [
|
||||
['route' => 'static-events', 'title' => 'Events', 'order' => 1, 'auth_required' => false],
|
||||
['route' => 'static-faq', 'title' => 'FAQ', 'order' => 2],
|
||||
],
|
||||
],
|
||||
['title' => 'Help', 'links' => []],
|
||||
],
|
||||
'tagline' => 'Your time is currency',
|
||||
],
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame(['en', 'nl'], $config['langs']);
|
||||
$this->assertSame(['work'], $config['payment_types'], 'no payment types added');
|
||||
$this->assertSame([[
|
||||
'title' => 'Contact',
|
||||
'links' => [['url' => 'mailto:hello@example.test', 'title' => 'Mail us', 'order' => 1]],
|
||||
]], $config['footer']['sections'], 'no sections, links or keys inside a link added');
|
||||
$this->assertSame('Your time is currency', $config['footer']['tagline'], 'keys next to a list still merge');
|
||||
}
|
||||
|
||||
public function test_a_missing_list_is_added_whole_and_an_empty_one_is_kept(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'model_indices' => [],
|
||||
'empty_keyed' => [],
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'model_indices' => ['users_index', 'posts_index'],
|
||||
'empty_keyed' => ['limit' => 5],
|
||||
'mimes' => ['png', 'jpg'],
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame(['png', 'jpg'], $config['mimes'], 'a missing list is added whole');
|
||||
$this->assertSame([], $config['model_indices'], 'an empty list is the platform\'s choice');
|
||||
$this->assertSame(['limit' => 5], $config['empty_keyed'], 'an empty [] still takes keyed template keys');
|
||||
}
|
||||
|
||||
public function test_a_platform_whose_only_differences_are_lists_is_up_to_date(): void
|
||||
{
|
||||
$this->write("<?php\n\nreturn [\n 'langs' => ['en'],\n];\n", "<?php\n\nreturn [\n 'langs' => ['en', 'nl'],\n];\n");
|
||||
$before = File::get($this->path);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$this->assertSame($before, File::get($this->path));
|
||||
$this->assertSame([], File::glob("{$this->backupDir}/test-config.php.backup.*"), 'no backup for a file that is not changed');
|
||||
}
|
||||
|
||||
public function test_a_trailing_comment_of_the_line_above_is_not_copied(): void
|
||||
{
|
||||
$this->write("<?php\n\nreturn [\n 'days' => 365 // 1 year\n];\n",
|
||||
"<?php\n\nreturn [\n 'days' => 365, // 1 year\n // Allow edits below the minimum.\n 'edits' => false,\n];\n");
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$source = File::get($this->path);
|
||||
$this->assertSame(1, substr_count($source, '// 1 year'), $source);
|
||||
$this->assertStringContainsString(" // Allow edits below the minimum.\n 'edits' => false,", $source);
|
||||
$this->assertFalse($this->load()['edits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A key defined twice: PHP keeps the last definition, so a new key must go into that one. Platform
|
||||
* configs copied from the old default template define profile_inactive twice; merging into the
|
||||
* first block failed with "keys still missing after merge" (2026-10-14).
|
||||
*/
|
||||
public function test_a_key_defined_twice_gets_new_keys_in_its_last_definition(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'inactive' => [
|
||||
'days' => 100,
|
||||
],
|
||||
'other' => 1,
|
||||
'inactive' => [
|
||||
'days' => 200,
|
||||
],
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'inactive' => [
|
||||
'days' => 200,
|
||||
'scheduler' => ['time' => '04:30'],
|
||||
],
|
||||
'other' => 1,
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$this->assertSame(['days' => 200, 'scheduler' => ['time' => '04:30']], $this->load()['inactive']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The check that existing values are unchanged compares closures by their source text, read by
|
||||
* line number. Read after the merge was written, a key inserted above a closure shifted those
|
||||
* lines and every merge into timebank_cc.php failed with "existing values changed" (2026-10-14).
|
||||
*/
|
||||
public function test_a_key_inserted_above_a_closure_merges(): void
|
||||
{
|
||||
// New top-level keys are appended at the end; a key inside an earlier array lands above the closure.
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'calls' => [
|
||||
'a' => 1,
|
||||
],
|
||||
'rule' => function ($value) {
|
||||
return $value > 1;
|
||||
},
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'calls' => [
|
||||
'a' => 1,
|
||||
'b' => [
|
||||
'one' => 1,
|
||||
'two' => 2,
|
||||
],
|
||||
],
|
||||
'rule' => function ($value) {
|
||||
return $value > 1;
|
||||
},
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$merged = $this->load();
|
||||
$this->assertSame(['one' => 1, 'two' => 2], $merged['calls']['b']);
|
||||
$this->assertTrue($merged['rule'](2));
|
||||
}
|
||||
|
||||
public function test_dry_run_changes_nothing(): void
|
||||
{
|
||||
$current = "<?php\n\nreturn [\n 'a' => 1,\n];\n";
|
||||
$this->write($current, "<?php\n\nreturn [\n 'a' => 1,\n 'b' => env('CONFIG_MERGE_TEST_URL'),\n];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'test-config', '--dry-run' => true])
|
||||
->expectsOutput('Found 1 new configuration key(s):')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSame($current, File::get($this->path));
|
||||
}
|
||||
|
||||
public function test_an_up_to_date_config_is_left_alone(): void
|
||||
{
|
||||
$current = "<?php\n\nreturn [\n 'a' => env('X', 1),\n];\n";
|
||||
$this->write($current, "<?php\n\nreturn [\n 'a' => 2,\n];\n");
|
||||
|
||||
$this->merge()->expectsOutput(' test-config: No new keys found')->assertExitCode(0);
|
||||
|
||||
$this->assertSame($current, File::get($this->path));
|
||||
}
|
||||
|
||||
public function test_a_backup_is_made_and_only_five_are_kept(): void
|
||||
{
|
||||
for ($i = 1; $i <= 7; $i++) {
|
||||
$this->write("<?php\n\nreturn [\n 'a' => 1,\n];\n", "<?php\n\nreturn [\n 'a' => 1,\n 'k{$i}' => {$i},\n];\n");
|
||||
$this->merge()->assertExitCode(0);
|
||||
// Backups are named by the second.
|
||||
touch(collect(File::glob("{$this->backupDir}/test-config.php.backup.*"))->last(), time() - 100 + $i);
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
$this->assertCount(5, File::glob("{$this->backupDir}/test-config.php.backup.*"));
|
||||
}
|
||||
|
||||
public function test_a_key_whose_parent_is_not_a_literal_array_is_skipped_safely(): void
|
||||
{
|
||||
$current = "<?php\n\nreturn [\n 'computed' => array_merge(['a' => 1], []),\n 'plain' => [\n 'a' => 1,\n ],\n];\n";
|
||||
$this->write($current, "<?php\n\nreturn [\n 'computed' => [\n 'a' => 1,\n 'b' => 2,\n ],\n 'plain' => [\n 'a' => 1,\n 'b' => 2,\n ],\n];\n");
|
||||
|
||||
$this->merge()
|
||||
->expectsOutputToContain('computed.b: not merged')
|
||||
->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame(['a' => 1, 'b' => 2], $config['plain']);
|
||||
$this->assertSame(['a' => 1], $config['computed']);
|
||||
}
|
||||
|
||||
public function test_missing_files_are_reported(): void
|
||||
{
|
||||
File::put($this->path, "<?php\n\nreturn [];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'test-config'])
|
||||
->expectsOutput('⊘ test-config: Example file not found (config/test-config.php.example)')
|
||||
->assertExitCode(0);
|
||||
|
||||
File::delete($this->path);
|
||||
File::put($this->examplePath, "<?php\n\nreturn [];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'test-config'])
|
||||
->expectsOutput('⊘ test-config: Active config not found (config/test-config.php) - run deployment first')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_it_works_from_any_working_directory(): void
|
||||
{
|
||||
$this->write("<?php\n\nreturn [\n 'a' => 1,\n];\n", "<?php\n\nreturn [\n 'a' => 1,\n 'b' => 2,\n];\n");
|
||||
|
||||
$cwd = getcwd();
|
||||
chdir(sys_get_temp_dir());
|
||||
|
||||
try {
|
||||
$this->merge()->assertExitCode(0);
|
||||
} finally {
|
||||
chdir($cwd);
|
||||
}
|
||||
|
||||
$this->assertSame(2, $this->load()['b']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* A platform may use its own config name (TIMEBANK_CONFIG=myorg, config/myorg.php). config:merge used
|
||||
* to know only themes, timebank-default and timebank_cc, so such a config never received new keys.
|
||||
* Without an .example of its own, it is merged from config/timebank-default.php.example.
|
||||
*/
|
||||
class ConfigMergePlatformNameTest extends TestCase
|
||||
{
|
||||
private string $name = 'config-merge-test-platform';
|
||||
private string $path;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->path = base_path("config/{$this->name}.php");
|
||||
config(['app.timebank_config' => $this->name]);
|
||||
$this->cleanup();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanup();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function cleanup(): void
|
||||
{
|
||||
File::delete([$this->path, "{$this->path}.example"]);
|
||||
|
||||
foreach (File::glob(storage_path("config-backups/{$this->name}.php.backup.*")) as $backup) {
|
||||
File::delete($backup);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_a_platform_config_with_its_own_name_gets_new_keys_from_the_default_template(): void
|
||||
{
|
||||
File::put($this->path, "<?php\n\nreturn [\n 'own_setting' => 'kept',\n];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => $this->name, '--force' => true])->assertExitCode(0);
|
||||
|
||||
$merged = include $this->path;
|
||||
|
||||
$this->assertSame('kept', $merged['own_setting']);
|
||||
$this->assertSame(['App\Models\Admin', 'App\Models\Bank'], $merged['account_info']['privileged_viewer_types']);
|
||||
}
|
||||
|
||||
public function test_all_includes_the_platform_config(): void
|
||||
{
|
||||
File::put($this->path, "<?php\n\nreturn [\n 'own_setting' => 'kept',\n];\n");
|
||||
|
||||
$this->artisan('config:merge', ['--all' => true, '--dry-run' => true])
|
||||
->expectsOutputToContain("Config: {$this->name}")
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_its_own_example_wins_over_the_default_template(): void
|
||||
{
|
||||
File::put($this->path, "<?php\n\nreturn [\n 'a' => 1,\n];\n");
|
||||
File::put("{$this->path}.example", "<?php\n\nreturn [\n 'a' => 1,\n 'b' => 2,\n];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => $this->name, '--force' => true])->assertExitCode(0);
|
||||
|
||||
$this->assertSame(['a' => 1, 'b' => 2], include $this->path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Laravel\Jetstream\Http\Livewire\ApiTokenManager;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateApiTokenTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_tokens_can_be_created()
|
||||
{
|
||||
if (! Features::hasApiFeatures()) {
|
||||
return $this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
|
||||
|
||||
Livewire::test(ApiTokenManager::class)
|
||||
->set(['createApiTokenForm' => [
|
||||
'name' => 'Test Token',
|
||||
'permissions' => [
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
]])
|
||||
->call('createApiToken');
|
||||
|
||||
$this->assertCount(1, $user->fresh()->tokens);
|
||||
$this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
|
||||
$this->assertTrue($user->fresh()->tokens->first()->can('read'));
|
||||
$this->assertFalse($user->fresh()->tokens->first()->can('delete'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\Profile\DeleteUserForm;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon; // Import Carbon
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeleteAccountTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_profile_can_be_deleted()
|
||||
{
|
||||
$this->actingAs($initialUser = User::factory()->create());
|
||||
$this->setActiveProfile($initialUser);
|
||||
|
||||
// Assuming 'password' is the default factory password or the one set for the user
|
||||
$component = Livewire::test(DeleteUserForm::class)
|
||||
->set('password', 'password')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
|
||||
|
||||
// Fetch the user again from the database to get the updated attributes
|
||||
$userAfterDeletionAttempt = User::find($initialUser->id);
|
||||
|
||||
// 1. Assert that the user record still exists (since it's a custom soft delete)
|
||||
$this->assertNotNull($userAfterDeletionAttempt, 'User record should still exist in the database.');
|
||||
|
||||
// 2. Assert that the 'deleted_at' column is not null
|
||||
$this->assertNotNull(
|
||||
$userAfterDeletionAttempt->deleted_at,
|
||||
"The 'deleted_at' column should be populated."
|
||||
);
|
||||
|
||||
// 3. Optional: Assert that 'deleted_at' is a valid date instance (if cast)
|
||||
// or a string in the expected format.
|
||||
// If 'deleted_at' is cast to a Carbon instance in your User model:
|
||||
if (isset($userAfterDeletionAttempt->getCasts()['deleted_at']) &&
|
||||
in_array($userAfterDeletionAttempt->getCasts()['deleted_at'], ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) {
|
||||
$this->assertInstanceOf(
|
||||
Carbon::class,
|
||||
$userAfterDeletionAttempt->deleted_at,
|
||||
"The 'deleted_at' column should be a Carbon instance."
|
||||
);
|
||||
} else {
|
||||
// If it's a string, you might check its format (less robust)
|
||||
$this->assertIsString($userAfterDeletionAttempt->deleted_at);
|
||||
// Example: Basic check if it looks like a datetime string
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
|
||||
$userAfterDeletionAttempt->deleted_at,
|
||||
"The 'deleted_at' column should be a valid datetime string."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Assert that the user is no longer authenticated
|
||||
$this->assertFalse(Auth::check(), 'User should be logged out after deletion.');
|
||||
|
||||
// Assert the redirect
|
||||
$component->assertRedirect(route('goodbye-deleted-user'));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_before_account_can_be_deleted()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
$this->setActiveProfile($user);
|
||||
|
||||
Livewire::test(DeleteUserForm::class)
|
||||
->set('password', 'wrong-password')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard())
|
||||
->assertHasErrors(['password']);
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* The form works on the active profile, which the login always sets
|
||||
*/
|
||||
private function setActiveProfile(User $user): void
|
||||
{
|
||||
session([
|
||||
'activeProfileType' => User::class,
|
||||
'activeProfileId' => $user->id,
|
||||
'active_guard' => 'web',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Laravel\Jetstream\Http\Livewire\ApiTokenManager;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeleteApiTokenTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_tokens_can_be_deleted()
|
||||
{
|
||||
if (! Features::hasApiFeatures()) {
|
||||
return $this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
|
||||
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
'token' => Str::random(40),
|
||||
'abilities' => ['create', 'read'],
|
||||
]);
|
||||
|
||||
Livewire::test(ApiTokenManager::class)
|
||||
->set(['apiTokenIdBeingDeleted' => $token->id])
|
||||
->call('deleteApiToken');
|
||||
|
||||
$this->assertCount(0, $user->fresh()->tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered()
|
||||
{
|
||||
if (! Features::enabled(Features::emailVerification())) {
|
||||
return $this->markTestSkipped('Email verification not enabled.');
|
||||
}
|
||||
|
||||
$user = User::factory()->withPersonalTeam()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/email/verify');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified()
|
||||
{
|
||||
if (! Features::enabled(Features::emailVerification())) {
|
||||
return $this->markTestSkipped('Email verification not enabled.');
|
||||
}
|
||||
|
||||
Event::fake();
|
||||
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['type' => 'user', 'id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(\App\Events\ProfileVerified::class);
|
||||
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
// Profiles verify through /email/verify/{type}/..., then land on the localized confirmation page
|
||||
$response->assertRedirect();
|
||||
$this->assertStringEndsWith('/email/verified', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function test_email_can_not_verified_with_invalid_hash()
|
||||
{
|
||||
if (! Features::enabled(Features::emailVerification())) {
|
||||
return $this->markTestSkipped('Email verification not enabled.');
|
||||
}
|
||||
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['type' => 'user', 'id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* health:check reports to healthchecks.io: a plain ping when every service is
|
||||
* fine, /fail with the reasons when one is not (see HealthCheck).
|
||||
*/
|
||||
class HealthCheckCommandTest extends TestCase
|
||||
{
|
||||
private const PING = 'https://hc-ping.test/abc-123';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'timebank_cc.health_monitor.enabled' => true,
|
||||
'timebank_cc.health_monitor.healthcheck_url' => self::PING,
|
||||
'timebank_cc.health_monitor.website_url' => 'https://site.test',
|
||||
'timebank_cc.health_monitor.disk_max_percent' => 100,
|
||||
'elasticsearch.host' => 'es.test:9200',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_healthy_run_sends_a_plain_ping(): void
|
||||
{
|
||||
Http::fake([
|
||||
'es.test:9200/*' => Http::response(['status' => 'yellow']),
|
||||
'site.test*' => Http::response('ok'),
|
||||
'hc-ping.test/*' => Http::response('OK'),
|
||||
]);
|
||||
|
||||
$this->artisan('health:check')->assertExitCode(0);
|
||||
|
||||
Http::assertSent(fn (Request $r) => $r->url() === self::PING);
|
||||
Http::assertNotSent(fn (Request $r) => str_ends_with($r->url(), '/fail'));
|
||||
}
|
||||
|
||||
public function test_a_failing_service_is_reported_with_its_reason(): void
|
||||
{
|
||||
Http::fake([
|
||||
'es.test:9200/*' => Http::response(['status' => 'red']),
|
||||
'site.test*' => Http::response('down', 500),
|
||||
'hc-ping.test/*' => Http::response('OK'),
|
||||
]);
|
||||
|
||||
$this->artisan('health:check')->assertExitCode(1);
|
||||
|
||||
Http::assertSent(function (Request $r) {
|
||||
return $r->url() === self::PING . '/fail'
|
||||
&& str_contains($r->body(), 'elasticsearch: cluster status red')
|
||||
&& str_contains($r->body(), 'website: https://site.test answered HTTP 500');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_a_full_disk_fails(): void
|
||||
{
|
||||
config(['timebank_cc.health_monitor.disk_max_percent' => 1]);
|
||||
|
||||
Http::fake([
|
||||
'es.test:9200/*' => Http::response(['status' => 'green']),
|
||||
'site.test*' => Http::response('ok'),
|
||||
'hc-ping.test/*' => Http::response('OK'),
|
||||
]);
|
||||
|
||||
$this->artisan('health:check')->assertExitCode(1);
|
||||
|
||||
Http::assertSent(fn (Request $r) => $r->url() === self::PING . '/fail'
|
||||
&& str_contains($r->body(), 'disk:'));
|
||||
}
|
||||
|
||||
public function test_it_runs_every_five_minutes(): void
|
||||
{
|
||||
$this->artisan('schedule:list')->expectsOutputToContain('health:check');
|
||||
|
||||
$event = collect(app(\Illuminate\Console\Scheduling\Schedule::class)->events())
|
||||
->first(fn ($e) => str_contains($e->command ?? '', 'health:check'));
|
||||
|
||||
$this->assertNotNull($event);
|
||||
$this->assertSame('*/5 * * * *', $event->expression);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Livewire\Mechanisms\HandleComponents\CorruptComponentPayloadException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LivewireCorruptPayloadNotReportedTest extends TestCase
|
||||
{
|
||||
public function test_corrupt_livewire_payloads_are_not_reported(): void
|
||||
{
|
||||
$handler = $this->app->make(ExceptionHandler::class);
|
||||
|
||||
$this->assertFalse($handler->shouldReport(new CorruptComponentPayloadException));
|
||||
$this->assertTrue($handler->shouldReport(new \RuntimeException('real error')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\MainPage\CallCardCarousel;
|
||||
use App\Http\Livewire\ReactionButton;
|
||||
use App\Models\User;
|
||||
use Cog\Laravel\Love\ReactionType\Models\ReactionType;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Livewire lets the browser change any public property that is not #[Locked]. On production a bot sent arrays into
|
||||
* ReactionButton and the call carousel (2026-10-19), and ReactionButton's own permission flags could be switched:
|
||||
* requiresInteraction off gave a star without a transaction, interactionMethod named any method of the visitor's
|
||||
* profile. Those properties are locked now, and the actions re-check everything on the server.
|
||||
*/
|
||||
class LivewireLockedPropertiesTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private User $me;
|
||||
private User $other;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Laravel Love caches reaction types in a static array that survives the rollback of an earlier test
|
||||
(new \ReflectionProperty(ReactionType::class, 'nameCache'))->setValue(null, []);
|
||||
foreach (['Like' => 1, 'Star' => 1] as $name => $mass) {
|
||||
ReactionType::query()->firstOrCreate(['name' => $name], ['mass' => $mass]);
|
||||
}
|
||||
config([config('app.timebank_config') . '.reactions.star' => ['enabled' => true, 'only_with_interaction' => true, 'interaction' => 'hasTransactionsWith']]);
|
||||
config([config('app.timebank_config') . '.reactions.like' => ['enabled' => true, 'only_with_interaction' => false, 'interaction' => 'hasTransactionsWith']]);
|
||||
|
||||
[$this->me, $this->other] = User::factory()->count(2)->create();
|
||||
$this->me->refresh();
|
||||
$this->other->refresh();
|
||||
$this->actAs($this->me);
|
||||
}
|
||||
|
||||
private function actAs(User $user): void
|
||||
{
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id, 'active_guard' => 'web']);
|
||||
}
|
||||
|
||||
private function button(string $type)
|
||||
{
|
||||
return Livewire::test(ReactionButton::class, ['typeName' => $type, 'modelInstance' => $this->other]);
|
||||
}
|
||||
|
||||
private function hasReacted(string $type): bool
|
||||
{
|
||||
return $this->me->fresh()->viaLoveReacter()->hasReactedTo($this->other->fresh(), $type);
|
||||
}
|
||||
|
||||
public function test_the_permission_flags_cannot_be_changed_from_the_browser(): void
|
||||
{
|
||||
foreach (['requiresInteraction' => false, 'isEnabled' => true, 'canReact' => true, 'interactionMethod' => 'delete', 'typeName' => ['x'], 'reactedByReactor' => false] as $property => $value) {
|
||||
try {
|
||||
$this->button('star')->set($property, $value);
|
||||
$this->fail("{$property} could be set from the browser");
|
||||
} catch (CannotUpdateLockedPropertyException) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_the_confirmation_dialog_stays_settable(): void
|
||||
{
|
||||
$this->button('like')->set('showConfirmModal', true)->assertSet('showConfirmModal', true);
|
||||
}
|
||||
|
||||
public function test_a_star_needs_a_transaction_also_when_the_action_is_called_directly(): void
|
||||
{
|
||||
$this->button('star')->assertSet('canReact', false)->call('confirmReaction');
|
||||
|
||||
$this->assertFalse($this->hasReacted('star'));
|
||||
}
|
||||
|
||||
public function test_a_like_works_once(): void
|
||||
{
|
||||
$component = $this->button('like')->call('confirmReaction')->call('confirmReaction');
|
||||
|
||||
$this->assertTrue($this->hasReacted('like'));
|
||||
$component->assertSet('count', 1);
|
||||
$this->assertSame(1, DB::table('love_reactions')->where('reactant_id', $this->other->fresh()->love_reactant_id)->count());
|
||||
}
|
||||
|
||||
public function test_an_unknown_reaction_type_becomes_a_like(): void
|
||||
{
|
||||
Livewire::test(ReactionButton::class, ['typeName' => 'no-such-type', 'modelInstance' => $this->other])
|
||||
->assertSet('typeName', 'like')
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_a_profile_switch_in_another_tab_is_refused(): void
|
||||
{
|
||||
$component = $this->button('like');
|
||||
$third = User::factory()->create();
|
||||
session(['activeProfileId' => $third->id]);
|
||||
|
||||
$component->call('confirmReaction');
|
||||
|
||||
$this->assertFalse($this->hasReacted('like'));
|
||||
}
|
||||
|
||||
public function test_the_call_carousel_cannot_be_changed_from_the_browser(): void
|
||||
{
|
||||
$this->expectException(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
Livewire::test(CallCardCarousel::class, ['related' => false])->assertStatus(200)->set('calls', [1, 2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* livewire/update is rate limited (RouteServiceProvider: 'livewire'):
|
||||
* 120 a minute per IP for guests, 500 a minute per logged-in user.
|
||||
*
|
||||
* On 2026-10-08 one bot posted tampered payloads there for 9 hours, up to
|
||||
* ~850 a minute.
|
||||
*/
|
||||
class LivewireRateLimitTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Keep the limiter's counters out of the dev site's Redis (see
|
||||
// PdfRateLimitTest for why the limiter is rebuilt).
|
||||
config(['cache.default' => 'array', 'cache.limiter' => 'array']);
|
||||
$this->app->forgetInstance(\Illuminate\Cache\RateLimiter::class);
|
||||
RateLimiter::clearResolvedInstances();
|
||||
$this->app->getProvider(\App\Providers\RouteServiceProvider::class)->configureRateLimiting();
|
||||
}
|
||||
|
||||
private function update(): \Illuminate\Testing\TestResponse
|
||||
{
|
||||
// A valid CSRF token, as the bot had: CSRF is checked before the
|
||||
// limiter, and a request without one is refused (419) uncounted.
|
||||
// An empty payload: the limit counts requests, not successes.
|
||||
return $this->withSession(['_token' => 'test-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-token')
|
||||
->postJson(route('default.livewire.update'), ['components' => []]);
|
||||
}
|
||||
|
||||
public function test_the_update_route_is_throttled(): void
|
||||
{
|
||||
$route = app('router')->getRoutes()->getByName('default.livewire.update');
|
||||
|
||||
$this->assertContains('throttle:livewire', $route->gatherMiddleware());
|
||||
}
|
||||
|
||||
public function test_a_guest_is_refused_after_120_a_minute(): void
|
||||
{
|
||||
for ($i = 1; $i <= 120; $i++) {
|
||||
$this->assertNotSame(429, $this->update()->status(), "Request $i is within the limit.");
|
||||
}
|
||||
|
||||
$refused = $this->update();
|
||||
|
||||
$refused->assertStatus(429);
|
||||
$this->assertSame('120', (string) $refused->headers->get('X-RateLimit-Limit'));
|
||||
}
|
||||
|
||||
public function test_a_logged_in_user_gets_500_a_minute_of_their_own(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// A guest on the same IP has used up the guest allowance...
|
||||
for ($i = 0; $i < 121; $i++) {
|
||||
$this->update();
|
||||
}
|
||||
$this->update()->assertStatus(429);
|
||||
|
||||
// ...which does not touch the user's.
|
||||
$response = $this->actingAs($user)->update();
|
||||
|
||||
$this->assertNotSame(429, $response->status());
|
||||
$this->assertSame('500', (string) $response->headers->get('X-RateLimit-Limit'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Mailing;
|
||||
use App\Models\MailingBounce;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Also: blocks against our sending server (Microsoft's S3150 block list,
|
||||
* "550 5.7.1") suppressed hotmail addresses on 2026-10-10, and a backlog of
|
||||
* August bounces was recorded as if it bounced that day.
|
||||
*
|
||||
* A second bounce report for the same address and mailing (unique index)
|
||||
* updates the first. Inserting it threw, and from 2026-09-27 at the latest
|
||||
* that stopped every hourly bounce run at the same message.
|
||||
*/
|
||||
class MailingBounceRecordTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private function mailing(): Mailing
|
||||
{
|
||||
return Mailing::create([
|
||||
'title' => 'Bounce test',
|
||||
'type' => 'general_newsletter',
|
||||
'subject' => ['en' => 'Bounce test'],
|
||||
'status' => 'sent',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_second_report_for_the_same_mailing_updates_the_first(): void
|
||||
{
|
||||
$mailing = $this->mailing();
|
||||
|
||||
MailingBounce::recordBounce('Bounce.Test@Example.test', 'soft', 'mailbox full', $mailing->id);
|
||||
$bounce = MailingBounce::recordBounce('bounce.test@example.test ', 'hard', 'user unknown', $mailing->id);
|
||||
|
||||
$rows = MailingBounce::where('email', 'bounce.test@example.test')->get();
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame($bounce->id, $rows->first()->id);
|
||||
$this->assertSame('hard', $rows->first()->bounce_type);
|
||||
$this->assertSame('user unknown', $rows->first()->bounce_reason);
|
||||
}
|
||||
|
||||
public function test_bounces_without_a_mailing_still_add_up(): void
|
||||
{
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard', 'user unknown');
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard', 'user unknown');
|
||||
|
||||
$this->assertSame(2, MailingBounce::where('email', 'bounce.test@example.test')->count());
|
||||
}
|
||||
|
||||
public function test_other_mailings_get_their_own_row(): void
|
||||
{
|
||||
$first = $this->mailing();
|
||||
$second = $this->mailing();
|
||||
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard', 'user unknown', $first->id);
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard', 'user unknown', $second->id);
|
||||
|
||||
$this->assertSame(2, MailingBounce::where('email', 'bounce.test@example.test')->count());
|
||||
}
|
||||
|
||||
private function thresholds(int $windowDays = 30): void
|
||||
{
|
||||
$key = config('app.timebank_config') . '.mailing.bounce_thresholds';
|
||||
config([
|
||||
"{$key}.suppression_threshold" => 1,
|
||||
"{$key}.verification_reset_threshold" => 1,
|
||||
"{$key}.counting_window_days" => $windowDays,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_block_against_our_server_does_not_suppress_the_address(): void
|
||||
{
|
||||
$this->thresholds();
|
||||
$user = User::factory()->create(['email' => 'bounce.test@example.test', 'email_verified_at' => now()]);
|
||||
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard',
|
||||
"smtp; 550 5.7.1 Unfortunately, messages from [195.190.28.89] weren't sent. Please contact your Internet service provider since part of their network is on our block list (S3150).");
|
||||
|
||||
$this->assertFalse(MailingBounce::isSuppressed('bounce.test@example.test'));
|
||||
$this->assertNotNull($user->fresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_an_unknown_user_still_suppresses_the_address(): void
|
||||
{
|
||||
$this->thresholds();
|
||||
$user = User::factory()->create(['email' => 'bounce.test@example.test', 'email_verified_at' => now()]);
|
||||
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard', 'smtp; 550 5.1.1 user unknown');
|
||||
|
||||
$this->assertTrue(MailingBounce::isSuppressed('bounce.test@example.test'));
|
||||
$this->assertNull($user->fresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_a_missing_address_counts_even_with_a_5_7_code(): void
|
||||
{
|
||||
$this->thresholds();
|
||||
|
||||
MailingBounce::recordBounce('bounce.test@example.test', 'hard',
|
||||
'smtp; 550 5.7.1 <bounce.test@example.test>: Recipient address rejected: Recipient not found');
|
||||
|
||||
$this->assertTrue(MailingBounce::isSuppressed('bounce.test@example.test'));
|
||||
}
|
||||
|
||||
public function test_a_bounce_is_dated_when_it_happened_and_old_ones_do_not_count(): void
|
||||
{
|
||||
$this->thresholds(30);
|
||||
$when = now()->subDays(70)->startOfSecond();
|
||||
|
||||
$bounce = MailingBounce::recordBounce('bounce.test@example.test', 'hard', 'smtp; 550 5.1.1 user unknown', null, $when);
|
||||
|
||||
$this->assertTrue($bounce->fresh()->bounced_at->equalTo($when));
|
||||
$this->assertFalse(MailingBounce::isSuppressed('bounce.test@example.test'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin management links are grouped into the Editorial and Administration dropdowns;
|
||||
* other profiles keep their plain links.
|
||||
*/
|
||||
class NavigationGroupsTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
private function actAsAdmin(array $permissions): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\nav-test-role", 'guard_name' => 'web']);
|
||||
foreach ($permissions as $permission) {
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']));
|
||||
}
|
||||
$user->assignRole($role);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id, 'active_guard' => 'admin']);
|
||||
}
|
||||
|
||||
private function navigation(): string
|
||||
{
|
||||
return view('navigation-menu')->render();
|
||||
}
|
||||
|
||||
/** Text of the desktop dropdown panel for a group (between its label and the panel end). */
|
||||
private function group(string $html, string $label): ?string
|
||||
{
|
||||
if (!preg_match('/' . preg_quote($label, '/') . '\s*<svg.*?<template x-teleport="body">(.*?)<\/template>/s', $html, $match)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim(preg_replace('/\s+/', ' ', strip_tags($match[1])));
|
||||
}
|
||||
|
||||
public function test_admin_with_all_permissions_gets_both_groups(): void
|
||||
{
|
||||
$this->actAsAdmin(['manage posts', 'manage polls', 'manage mailings', 'manage categories', 'manage tags', 'manage profiles', 'manage roles']);
|
||||
|
||||
$html = $this->navigation();
|
||||
|
||||
$this->assertSame('Posts Polls Calls Mailings Categories Tags', $this->group($html, 'Editorial'));
|
||||
$this->assertSame('Profiles Roles', $this->group($html, 'Administration'));
|
||||
}
|
||||
|
||||
public function test_hidden_links_and_empty_groups_are_left_out(): void
|
||||
{
|
||||
$this->actAsAdmin(['manage posts']);
|
||||
|
||||
$html = $this->navigation();
|
||||
|
||||
// Polls have their own permission now
|
||||
$this->assertSame('Posts Calls', $this->group($html, 'Editorial'));
|
||||
$this->assertNull($this->group($html, 'Administration'));
|
||||
$this->assertStringNotContainsString(route('roles.manage'), $html);
|
||||
$this->assertStringNotContainsString(route('mailings.index'), $html);
|
||||
}
|
||||
|
||||
public function test_user_profile_keeps_plain_links(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id, 'active_guard' => 'web']);
|
||||
|
||||
$html = $this->navigation();
|
||||
|
||||
$this->assertNull($this->group($html, 'Editorial'));
|
||||
$this->assertStringContainsString(route('calls.manage'), $html);
|
||||
$this->assertStringNotContainsString(route('profiles.manage'), $html);
|
||||
}
|
||||
|
||||
public function test_central_bank_keeps_plain_links(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['level' => 0]);
|
||||
$bank->managers()->attach($user);
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id, 'active_guard' => 'bank']);
|
||||
|
||||
$html = $this->navigation();
|
||||
|
||||
$this->assertNull($this->group($html, 'Administration'));
|
||||
$this->assertStringContainsString(route('profiles.manage'), $html);
|
||||
$this->assertStringContainsString(route('calls.manage'), $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\Pay;
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Namu\WireChat\Enums\ConversationType;
|
||||
use Namu\WireChat\Enums\MessageType;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
use Namu\WireChat\Models\Message;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Tests the "new recipient" confirmation modal shown in the Pay view when the
|
||||
* active profile has never had a chat conversation or payment with the
|
||||
* selected to-account holder.
|
||||
*
|
||||
* @group payment
|
||||
*/
|
||||
class PayNewRecipientConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
\DB::table('transaction_types')->insertOrIgnore([
|
||||
['id' => 1, 'name' => 'worked_hours', 'label' => 'Worked Hours', 'icon' => 'clock'],
|
||||
['id' => 6, 'name' => 'migration', 'label' => 'Migration', 'icon' => 'arrow-path'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function actingAsUserWithAccount(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
return [$user, $account];
|
||||
}
|
||||
|
||||
private function toAccountDetails(User $recipient, Account $recipientAccount): array
|
||||
{
|
||||
return [
|
||||
'accountId' => $recipientAccount->id,
|
||||
'accountName' => $recipientAccount->name,
|
||||
'holderId' => $recipient->id,
|
||||
'holderType' => User::class,
|
||||
'holderName' => $recipient->name,
|
||||
'holderLocation' => 'Testville',
|
||||
'holderPhoto' => $recipient->profile_photo_path,
|
||||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function selecting_a_recipient_with_no_history_opens_the_confirmation_modal()
|
||||
{
|
||||
[$user, ] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount));
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', true);
|
||||
$response->assertSet('toAccountId', null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function selecting_a_recipient_with_a_prior_transaction_skips_the_modal()
|
||||
{
|
||||
[$user, $userAccount] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount));
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', false);
|
||||
$response->assertSet('toAccountId', $recipientAccount->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function selecting_a_recipient_with_a_prior_transaction_in_reverse_direction_skips_the_modal()
|
||||
{
|
||||
[$user, $userAccount] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $recipientAccount->id,
|
||||
'to_account_id' => $userAccount->id,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount));
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', false);
|
||||
$response->assertSet('toAccountId', $recipientAccount->id);
|
||||
}
|
||||
|
||||
private function createPrivateConversation(User $user, User $recipient): Conversation
|
||||
{
|
||||
$conversation = new Conversation();
|
||||
$conversation->type = ConversationType::PRIVATE;
|
||||
$conversation->save();
|
||||
$conversation->participants()->create([
|
||||
'participantable_type' => User::class,
|
||||
'participantable_id' => $user->id,
|
||||
'role' => \Namu\WireChat\Enums\ParticipantRole::PARTICIPANT,
|
||||
]);
|
||||
$conversation->participants()->create([
|
||||
'participantable_type' => User::class,
|
||||
'participantable_id' => $recipient->id,
|
||||
'role' => \Namu\WireChat\Enums\ParticipantRole::PARTICIPANT,
|
||||
]);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function selecting_a_recipient_who_only_sent_messages_without_a_reply_still_shows_the_modal()
|
||||
{
|
||||
[$user, ] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$conversation = $this->createPrivateConversation($user, $recipient);
|
||||
|
||||
// Recipient messaged the active profile, but the active profile never replied.
|
||||
Message::create([
|
||||
'body' => 'Hello there',
|
||||
'sendable_type' => User::class,
|
||||
'sendable_id' => $recipient->id,
|
||||
'conversation_id' => $conversation->id,
|
||||
'type' => MessageType::TEXT,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount));
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', true);
|
||||
$response->assertSet('toAccountId', null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function selecting_a_recipient_the_active_profile_previously_messaged_skips_the_modal()
|
||||
{
|
||||
[$user, ] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$conversation = $this->createPrivateConversation($user, $recipient);
|
||||
|
||||
Message::create([
|
||||
'body' => 'Hi, thanks for reaching out',
|
||||
'sendable_type' => User::class,
|
||||
'sendable_id' => $user->id,
|
||||
'conversation_id' => $conversation->id,
|
||||
'type' => MessageType::TEXT,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount));
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', false);
|
||||
$response->assertSet('toAccountId', $recipientAccount->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function internal_transfer_to_own_profile_skips_the_modal()
|
||||
{
|
||||
[$user, $userAccount] = $this->actingAsUserWithAccount();
|
||||
|
||||
$secondOwnAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$details = [
|
||||
'accountId' => $secondOwnAccount->id,
|
||||
'accountName' => $secondOwnAccount->name,
|
||||
'holderId' => $user->id,
|
||||
'holderType' => User::class,
|
||||
'holderName' => $user->name,
|
||||
'holderLocation' => 'Testville',
|
||||
'holderPhoto' => $user->profile_photo_path,
|
||||
];
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $details);
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', false);
|
||||
$response->assertSet('toAccountId', $secondOwnAccount->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function confirming_new_recipient_applies_the_pending_selection()
|
||||
{
|
||||
[$user, ] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount))
|
||||
->call('confirmNewRecipient');
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', false);
|
||||
$response->assertSet('toAccountId', $recipientAccount->id);
|
||||
$response->assertSet('toHolderName', $recipient->name);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cancelling_new_recipient_clears_the_pending_selection()
|
||||
{
|
||||
[$user, ] = $this->actingAsUserWithAccount();
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $this->toAccountDetails($recipient, $recipientAccount))
|
||||
->call('cancelNewRecipient');
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', false);
|
||||
$response->assertSet('toAccountId', null);
|
||||
$response->assertSet('pendingToAccountDetails', null);
|
||||
$response->assertDispatched('focus-to-account-search');
|
||||
$response->assertDispatchedTo(\App\Http\Livewire\ToAccount::class, 'resetForm');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function unrecognised_holder_type_is_treated_as_unfamiliar_without_error()
|
||||
{
|
||||
[$user, ] = $this->actingAsUserWithAccount();
|
||||
|
||||
$details = [
|
||||
'accountId' => 999999,
|
||||
'accountName' => 'Fake Account',
|
||||
'holderId' => 1,
|
||||
'holderType' => 'App\\Models\\NotARealModel',
|
||||
'holderName' => 'Fake Holder',
|
||||
'holderLocation' => 'Nowhere',
|
||||
'holderPhoto' => null,
|
||||
];
|
||||
|
||||
$response = Livewire::test(Pay::class)
|
||||
->dispatch('toAccountDetails', $details);
|
||||
|
||||
$response->assertSet('isNewRecipientModalOpen', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Public PDFs are rate limited per IP (RouteServiceProvider: 'pdf').
|
||||
*
|
||||
* On 2026-10-07 a crawler fetched them ~11 times a second; every one was
|
||||
* rendered from scratch, the server ran out of memory and the kernel killed
|
||||
* MariaDB. A limited request must be refused before any PDF work starts.
|
||||
*/
|
||||
class PdfRateLimitTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Not the Redis store phpunit.xml configures: that is the dev site's
|
||||
// cache, and the limiter's counters would land there.
|
||||
config(['cache.default' => 'array', 'cache.limiter' => 'array']);
|
||||
|
||||
// The limiter was built at boot, on the Redis store, before the line
|
||||
// above ran: rebuild it, or its counters still land in the dev cache
|
||||
// (and survive into the next run). Its named limits live on the
|
||||
// instance, so they are registered again - without them 'pdf' reads
|
||||
// as a limit of 0 and every second request is refused.
|
||||
$this->app->forgetInstance(\Illuminate\Cache\RateLimiter::class);
|
||||
RateLimiter::clearResolvedInstances();
|
||||
$this->app->getProvider(\App\Providers\RouteServiceProvider::class)->configureRateLimiting();
|
||||
}
|
||||
|
||||
public function test_the_thirty_first_pdf_in_a_minute_is_refused(): void
|
||||
{
|
||||
// An id that does not exist: the limit counts requests, not
|
||||
// successes, and nothing is rendered.
|
||||
$url = route('call.pdf-a4-bw', ['id' => 999999999]);
|
||||
|
||||
for ($i = 1; $i <= 30; $i++) {
|
||||
$this->assertNotSame(429, $this->get($url)->status(), "Request $i is within the limit.");
|
||||
}
|
||||
|
||||
$refused = $this->get($url);
|
||||
|
||||
$refused->assertStatus(429);
|
||||
$this->assertNotNull($refused->headers->get('Retry-After'));
|
||||
$this->assertSame('30', (string) $refused->headers->get('X-RateLimit-Limit'));
|
||||
}
|
||||
|
||||
public function test_every_pdf_route_shares_one_allowance(): void
|
||||
{
|
||||
// Switching format must not open a fresh allowance: the crawler
|
||||
// fetched every variant of every call.
|
||||
$routes = ['call.pdf', 'call.pdf-bw', 'call.pdf-a4', 'call.pdf-a4-bw', 'post.pdf'];
|
||||
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$this->get(route($routes[$i % count($routes)], ['id' => 999999999]));
|
||||
}
|
||||
|
||||
$this->get(route('post.pdf', ['id' => 999999999]))->assertStatus(429);
|
||||
$this->get(route('call.pdf-bulk'))->assertStatus(429);
|
||||
$this->get(route('post.pdf-bulk'))->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_other_ips_keep_their_own_allowance(): void
|
||||
{
|
||||
$url = route('post.pdf', ['id' => 999999999]);
|
||||
|
||||
for ($i = 0; $i < 31; $i++) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.1'])->get($url);
|
||||
}
|
||||
|
||||
// The first IP is now refused...
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.1'])->get($url)->assertStatus(429);
|
||||
|
||||
// ...and a second one, behind its own limit, is not.
|
||||
$this->assertNotSame(
|
||||
429,
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.2'])->get($url)->status()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_search_has_its_own_limit_of_thirty_a_minute(): void
|
||||
{
|
||||
$route = app('router')->getRoutes()->getByName('search.show');
|
||||
|
||||
$this->assertContains('throttle:search', $route->gatherMiddleware());
|
||||
|
||||
$limit = RateLimiter::limiter('search')(request());
|
||||
|
||||
$this->assertSame(30, $limit->maxAttempts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Pages and assets that belong to one platform can be switched off or replaced by another platform
|
||||
* running the same code: the Android app, local group pages in the footer, email logos and favicons.
|
||||
*/
|
||||
class PlatformBrandingTest extends TestCase
|
||||
{
|
||||
private function platformConfig(array $values): void
|
||||
{
|
||||
$platform = config('app.timebank_config');
|
||||
foreach ($values as $key => $value) {
|
||||
config(["{$platform}.{$key}" => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_the_app_version_check_names_the_configured_apk(): void
|
||||
{
|
||||
$this->platformConfig(['android_app.enabled' => true, 'android_app.apk_file' => 'myorg-{version}.apk']);
|
||||
|
||||
$this->getJson('/app/version')
|
||||
->assertOk()
|
||||
->assertJsonPath('apkUrl', url('storage/apk/myorg-' . \App\Http\Controllers\AppVersionController::latest()['versionName'] . '.apk'));
|
||||
}
|
||||
|
||||
public function test_the_android_app_is_off_when_disabled(): void
|
||||
{
|
||||
$this->platformConfig(['android_app.enabled' => false]);
|
||||
|
||||
$this->get('/app/version')->assertNotFound();
|
||||
$this->get(route('static-android-app'))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_the_android_app_page_names_the_platform(): void
|
||||
{
|
||||
$this->platformConfig(['android_app.enabled' => true, 'platform_translations.en.platform_name' => 'Example Timebank']);
|
||||
|
||||
$this->get(route('static-android-app'))
|
||||
->assertOk()
|
||||
->assertDontSee('Timebank.cc Android')
|
||||
->assertSee('Example Timebank Android');
|
||||
}
|
||||
|
||||
public function test_the_footer_leaves_out_links_to_routes_this_platform_does_not_register(): void
|
||||
{
|
||||
$this->platformConfig(['footer.sections' => [[
|
||||
'title' => 'Community', 'order' => 1, 'visible' => true,
|
||||
'links' => [
|
||||
['route' => 'static-events', 'title' => 'Events', 'order' => 1, 'visible' => true],
|
||||
['route' => 'static-switched-off-page', 'title' => 'Switched off page', 'order' => 2, 'visible' => true],
|
||||
],
|
||||
]]]);
|
||||
|
||||
$html = Blade::render('<x-footer />');
|
||||
|
||||
$this->assertStringContainsString(route('static-events'), $html);
|
||||
$this->assertStringNotContainsString('Switched off page', $html);
|
||||
}
|
||||
|
||||
public function test_without_its_own_email_logo_a_platform_gets_a_text_logo_with_its_name(): void
|
||||
{
|
||||
$name = 'Test Platform ' . uniqid();
|
||||
$this->platformConfig(['platform_translations.' . app()->getLocale() . '.platform_name' => $name]);
|
||||
config([
|
||||
'themes.active' => 'uuro',
|
||||
'themes.themes.uuro.logos.email_logo' => 'app-images/does-not-exist.png',
|
||||
'themes.themes.uuro.logos.email_logo_inverted' => 'app-images/does-not-exist.png',
|
||||
]);
|
||||
|
||||
$paths = [theme_email_logo(), theme_email_logo(true)];
|
||||
|
||||
try {
|
||||
foreach ($paths as $path) {
|
||||
$this->assertStringStartsWith('app-images/generated/test-platform-', $path);
|
||||
$size = getimagesize(storage_path('app/public/' . $path));
|
||||
$this->assertSame([900, 700, 'image/png'], [$size[0], $size[1], $size['mime']]);
|
||||
}
|
||||
$this->assertNotSame($paths[0], $paths[1]);
|
||||
|
||||
// Cached: asking again gives the same file
|
||||
$this->assertSame($paths[0], theme_email_logo());
|
||||
} finally {
|
||||
foreach ($paths as $path) {
|
||||
@unlink(storage_path('app/public/' . $path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_the_timebank_cc_theme_falls_back_to_its_own_logo_and_an_uploaded_logo_wins(): void
|
||||
{
|
||||
// The tracked app_mail_logo.png is the Timebank.cc logo, part of the timebank_cc theme
|
||||
config(['themes.active' => 'timebank_cc', 'themes.themes.timebank_cc.logos.email_logo' => 'app-images/does-not-exist.png']);
|
||||
$this->assertSame('app-images/app_mail_logo.png', theme_email_logo());
|
||||
|
||||
// An uploaded theme logo is used, whatever the theme
|
||||
config(['themes.active' => 'uuro', 'themes.themes.uuro.logos.email_logo' => 'app-images/app_mail_logo_inverted.png']);
|
||||
$this->assertSame('app-images/app_mail_logo_inverted.png', theme_email_logo());
|
||||
}
|
||||
|
||||
public function test_favicons_come_from_the_theme_folder_only_when_it_holds_the_file(): void
|
||||
{
|
||||
config(['themes.active' => 'uuro', 'themes.themes.uuro.logos.favicon_dir' => null]);
|
||||
$this->assertSame(asset('favicon.ico'), theme_favicon('favicon.ico'));
|
||||
|
||||
// public/ itself holds favicon.ico, so use it as an existing "theme folder" and a missing one
|
||||
config(['themes.themes.uuro.logos.favicon_dir' => '/']);
|
||||
$this->assertSame(asset('favicon.ico'), theme_favicon('favicon.ico'));
|
||||
|
||||
config(['themes.themes.uuro.logos.favicon_dir' => 'build']);
|
||||
$this->assertSame(asset('favicon.ico'), theme_favicon('favicon.ico'));
|
||||
|
||||
config(['themes.themes.uuro.logos.favicon_dir' => 'storage/app-images']);
|
||||
$this->assertSame(asset('storage/app-images/app_logo.svg'), theme_favicon('app_logo.svg'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* When the platform folder has a config.php, that is the platform config, so config:merge merges new keys into
|
||||
* it: from config/<TIMEBANK_CONFIG>.php.example when that exists, else from the default template.
|
||||
*/
|
||||
class PlatformConfigMergeTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->root = sys_get_temp_dir() . '/platform-config-merge-' . uniqid();
|
||||
File::makeDirectory($this->root);
|
||||
config(['app.platform_path' => $this->root]);
|
||||
File::put("{$this->root}/config.php", "<?php\n\nreturn [\n 'own_setting' => 'kept',\n];\n");
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory($this->root);
|
||||
File::delete(File::glob(storage_path('config-backups/platform-config.php.backup.*')));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_a_platform_name_without_its_own_template_merges_from_the_default_template(): void
|
||||
{
|
||||
config(['app.timebank_config' => 'config-merge-test-platform']);
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'config-merge-test-platform', '--force' => true])->assertExitCode(0);
|
||||
|
||||
$merged = include "{$this->root}/config.php";
|
||||
$this->assertSame('kept', $merged['own_setting']);
|
||||
$this->assertArrayHasKey('account_info', $merged);
|
||||
$this->assertFalse(File::exists(base_path('config/config-merge-test-platform.php')), 'nothing is written to config/');
|
||||
$this->assertCount(1, File::glob(storage_path('config-backups/platform-config.php.backup.*')), 'the backup is named after the platform folder');
|
||||
}
|
||||
|
||||
public function test_a_platform_with_its_own_template_merges_from_that_one(): void
|
||||
{
|
||||
config(['app.timebank_config' => 'timebank_cc']);
|
||||
$own = (include base_path('config/timebank_cc.php.example'))['mail']['system_admin']['email'];
|
||||
$default = (include base_path('config/timebank-default.php.example'))['mail']['system_admin']['email'];
|
||||
$this->assertNotSame($own, $default, 'the two templates must differ here for this test to mean something');
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'timebank_cc', '--dry-run' => true])->assertExitCode(0);
|
||||
$this->assertSame("<?php\n\nreturn [\n 'own_setting' => 'kept',\n];\n", File::get("{$this->root}/config.php"), 'a dry run changes nothing');
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'timebank_cc', '--force' => true])->assertExitCode(0);
|
||||
$this->assertSame($own, (include "{$this->root}/config.php")['mail']['system_admin']['email']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The platform config templates must offer the same settings.
|
||||
*
|
||||
* timebank-default.php.example is the starting point for a new installation. It once fell 53 keys
|
||||
* behind timebank_cc.php.example, among them account_info.*_self / *_privileged: an installation
|
||||
* set up from it hid balances from their own owners and from admins. New settings must be added
|
||||
* to both templates.
|
||||
*/
|
||||
class PlatformConfigTemplatesTest extends TestCase
|
||||
{
|
||||
private function keys(string $file): array
|
||||
{
|
||||
return $this->flatten(require base_path("config/{$file}"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dotted paths of all leaves. A list (e.g. [1, 2, 3]) or an empty array is a value, not a branch.
|
||||
*/
|
||||
private function flatten(array $config, string $prefix = ''): array
|
||||
{
|
||||
$keys = [];
|
||||
foreach ($config as $key => $value) {
|
||||
$path = $prefix === '' ? (string) $key : "{$prefix}.{$key}";
|
||||
if (is_array($value) && $value !== [] && !array_is_list($value)) {
|
||||
$keys = array_merge($keys, $this->flatten($value, $path));
|
||||
} else {
|
||||
$keys[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function test_the_default_template_has_every_setting_of_the_timebank_cc_template(): void
|
||||
{
|
||||
$missing = array_diff($this->keys('timebank_cc.php.example'), $this->keys('timebank-default.php.example'));
|
||||
|
||||
$this->assertSame([], array_values($missing), 'Add these keys to config/timebank-default.php.example');
|
||||
}
|
||||
|
||||
public function test_the_timebank_cc_template_has_every_setting_of_the_default_template(): void
|
||||
{
|
||||
$missing = array_diff($this->keys('timebank-default.php.example'), $this->keys('timebank_cc.php.example'));
|
||||
|
||||
$this->assertSame([], array_values($missing), 'Add these keys to config/timebank_cc.php.example');
|
||||
}
|
||||
|
||||
public function test_the_default_template_does_not_carry_the_timebank_cc_brand(): void
|
||||
{
|
||||
$source = file_get_contents(base_path('config/timebank-default.php.example'));
|
||||
|
||||
$this->assertDoesNotMatchRegularExpression('/timebank\.cc/i', $source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Providers\PlatformServiceProvider;
|
||||
use Illuminate\Mail\Markdown;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The platform folder (platform_path(), outside git) holds everything that makes an installation one platform.
|
||||
* Every part is optional; without it the installation runs from the code as before. Each test builds its own
|
||||
* folder, and a public/ of its own, so the real platform/ and public/platform are never touched.
|
||||
*/
|
||||
class PlatformFolderTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private string $public;
|
||||
private string $originalPublic;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$base = sys_get_temp_dir() . '/platform-folder-test-' . uniqid();
|
||||
$this->root = "{$base}/platform";
|
||||
$this->public = "{$base}/public";
|
||||
File::makeDirectory($this->public, 0755, true);
|
||||
$this->originalPublic = public_path();
|
||||
$this->app->usePublicPath($this->public);
|
||||
config(['app.platform_path' => $this->root]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->app->usePublicPath($this->originalPublic);
|
||||
File::deleteDirectory(dirname($this->root));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function putFile(string $path, string $content): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname("{$this->root}/{$path}"));
|
||||
File::put("{$this->root}/{$path}", $content);
|
||||
}
|
||||
|
||||
/** Load the folder as the app does at start, and drop what was already cached. */
|
||||
private function loadPlatform(): void
|
||||
{
|
||||
$provider = new PlatformServiceProvider($this->app);
|
||||
$provider->register();
|
||||
$provider->boot();
|
||||
|
||||
$this->app['translator']->setLoaded([]);
|
||||
View::getFinder()->flush();
|
||||
$this->app->forgetInstance(Markdown::class);
|
||||
}
|
||||
|
||||
public function test_without_a_folder_nothing_changes(): void
|
||||
{
|
||||
$name = config('app.timebank_config');
|
||||
$before = [config($name), config('themes'), config('mail.markdown.paths'), __('Search'), View::getFinder()->getPaths()];
|
||||
|
||||
$this->loadPlatform();
|
||||
|
||||
$this->assertSame($before, [config($name), config('themes'), config('mail.markdown.paths'), __('Search'), View::getFinder()->getPaths()]);
|
||||
$this->assertSame(asset('favicon.ico'), theme_favicon('favicon.ico'));
|
||||
$this->assertSame(asset('storage/' . theme_email_logo()), theme_email_logo_url());
|
||||
}
|
||||
|
||||
public function test_its_config_is_the_platform_config(): void
|
||||
{
|
||||
$this->putFile('config.php', "<?php\n\nreturn ['platform_folder_marker' => 'from the folder', 'local_pages' => []];\n");
|
||||
|
||||
$this->loadPlatform();
|
||||
|
||||
$this->assertSame('from the folder', timebank_config('platform_folder_marker'));
|
||||
$this->assertNull(timebank_config('footer'), 'the folder replaces config/<name>.php, it does not merge with it');
|
||||
}
|
||||
|
||||
public function test_its_theme_becomes_the_active_theme(): void
|
||||
{
|
||||
$this->putFile('theme.php', "<?php\n\nreturn ['id' => 'acme', 'name' => 'Acme', 'colors' => ['brand' => '#123456']];\n");
|
||||
|
||||
$this->loadPlatform();
|
||||
|
||||
$this->assertSame('acme', theme_id());
|
||||
$this->assertSame('Acme', theme_name());
|
||||
$this->assertSame('#123456', theme_color('brand'));
|
||||
$this->assertStringContainsString('--color-brand: 18 52 86;', theme_css_vars());
|
||||
}
|
||||
|
||||
public function test_translations_are_overridden_key_by_key(): void
|
||||
{
|
||||
app()->setLocale('en');
|
||||
$base = json_decode(File::get(lang_path('en.json')), true);
|
||||
[$changed, $kept] = array_slice(array_keys($base), 10, 2);
|
||||
$baseWirechat = trans('wirechat::chat.labels.you');
|
||||
|
||||
$this->putFile('lang/en.json', json_encode([$changed => 'Changed by the platform', 'Only on this platform' => 'Platform text']));
|
||||
$this->putFile('lang/en/messages.php', "<?php\n\nreturn ['platform_folder_marker' => 'Group text'];\n");
|
||||
$this->putFile('lang/vendor/wirechat/en/chat.php', "<?php\n\nreturn ['labels' => ['you' => 'Me']];\n");
|
||||
|
||||
$this->loadPlatform();
|
||||
|
||||
$this->assertSame('Changed by the platform', __($changed));
|
||||
$this->assertSame($base[$kept], __($kept), 'keys the platform does not override keep their text');
|
||||
$this->assertSame('Platform text', __('Only on this platform'));
|
||||
$this->assertSame('Group text', __('messages.platform_folder_marker'));
|
||||
$this->assertNotSame('messages.platform_folder_marker', __('messages.' . array_key_first(require lang_path('en/messages.php'))), 'the rest of the group stays');
|
||||
$this->assertSame('Me', trans('wirechat::chat.labels.you'));
|
||||
$this->assertNotSame($baseWirechat, 'Me');
|
||||
$this->assertSame(trans('wirechat::chat.labels.attachment'), 'Attachment', 'the rest of the package group stays');
|
||||
}
|
||||
|
||||
public function test_templates_are_overridden_as_whole_files(): void
|
||||
{
|
||||
$this->putFile('views/logos/timebank_cc.blade.php', 'PLATFORM LOGO');
|
||||
$this->putFile('views/vendor/wirechat/components/divider.blade.php', 'PLATFORM DIVIDER');
|
||||
$this->putFile('views/mail/html/panel.blade.php', '<div>PLATFORM PANEL {{ $slot }}</div>');
|
||||
$this->putFile('views/platform-test-markdown.blade.php', "<x-mail::panel>\nHello\n</x-mail::panel>\n");
|
||||
|
||||
$this->loadPlatform();
|
||||
|
||||
$this->assertSame('PLATFORM LOGO', trim(view('logos.timebank_cc')->render()));
|
||||
$this->assertSame("{$this->root}/views/vendor/wirechat/components/divider.blade.php", View::getFinder()->find('wirechat::components.divider'));
|
||||
$this->assertStringContainsString('PLATFORM PANEL', (string) app(Markdown::class)->render('platform-test-markdown'));
|
||||
$this->assertStringContainsString('resources/views/emails', View::getFinder()->find('emails.layouts.html'), 'templates the platform does not override come from the code');
|
||||
}
|
||||
|
||||
public function test_public_files_are_served_from_the_folder_once_linked(): void
|
||||
{
|
||||
$this->putFile('public/favicon.ico', 'icon');
|
||||
$this->putFile('public/mail-logo.png', 'logo');
|
||||
|
||||
$this->loadPlatform();
|
||||
$this->assertSame(asset('favicon.ico'), theme_favicon('favicon.ico'), 'not served before the link exists');
|
||||
|
||||
$this->artisan('platform:link')->assertExitCode(0);
|
||||
|
||||
$this->assertSame("{$this->root}/public", readlink("{$this->public}/platform"));
|
||||
$this->assertSame(asset('platform/favicon.ico'), theme_favicon('favicon.ico'));
|
||||
$this->assertSame(asset('favicon-32x32.png'), theme_favicon('favicon-32x32.png'), 'a file the platform lacks comes from the code');
|
||||
$this->assertSame(asset('platform/mail-logo.png'), theme_email_logo_url());
|
||||
$this->assertSame("{$this->public}/platform/mail-logo.png", theme_email_logo_file());
|
||||
$this->assertSame(asset('storage/' . theme_email_logo(true)), theme_email_logo_url(true), 'no inverted logo in the folder');
|
||||
}
|
||||
|
||||
public function test_platform_link_is_idempotent_and_never_replaces_a_real_folder(): void
|
||||
{
|
||||
$this->artisan('platform:link')->expectsOutputToContain('nothing to link')->assertExitCode(0);
|
||||
$this->assertFalse(file_exists("{$this->public}/platform"));
|
||||
|
||||
$this->putFile('public/favicon.ico', 'icon');
|
||||
$this->artisan('platform:link')->assertExitCode(0);
|
||||
$this->artisan('platform:link')->expectsOutputToContain('already links')->assertExitCode(0);
|
||||
|
||||
File::deleteDirectory("{$this->root}/public");
|
||||
$this->artisan('platform:link')->expectsOutputToContain('Removed')->assertExitCode(0);
|
||||
$this->assertFalse(is_link("{$this->public}/platform"));
|
||||
|
||||
File::makeDirectory("{$this->public}/platform");
|
||||
$this->putFile('public/favicon.ico', 'icon');
|
||||
$this->artisan('platform:link')->expectsOutputToContain('not a link')->assertExitCode(1);
|
||||
$this->assertTrue(is_dir("{$this->public}/platform") && !is_link("{$this->public}/platform"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Notifications\NonUserPasswordResetNotification;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The shared head of the layouts (layouts/partials/brand-head): title, icons and the Open Graph preview for shared
|
||||
* links. Optional files are linked only when they exist (platform folder, theme, public/).
|
||||
*/
|
||||
class PlatformHeadTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private string $public;
|
||||
private string $originalPublic;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$base = sys_get_temp_dir() . '/platform-head-test-' . uniqid();
|
||||
$this->root = "{$base}/platform";
|
||||
$this->public = "{$base}/public";
|
||||
File::makeDirectory("{$this->root}/public", 0755, true);
|
||||
File::makeDirectory($this->public, 0755, true);
|
||||
$this->originalPublic = public_path();
|
||||
$this->app->usePublicPath($this->public);
|
||||
config(['app.platform_path' => $this->root]);
|
||||
// The layouts' @vite would look for the build manifest in the temporary public/
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
View::flushSections();
|
||||
$this->app->usePublicPath($this->originalPublic);
|
||||
File::deleteDirectory(dirname($this->root));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function renderHead(): string
|
||||
{
|
||||
return view('layouts.guest', ['slot' => new HtmlString('')])->render();
|
||||
}
|
||||
|
||||
public function test_open_graph_tags_use_property_and_the_title_is_escaped_once(): void
|
||||
{
|
||||
View::startSection('title', 'Terms & conditions');
|
||||
|
||||
$html = $this->renderHead();
|
||||
|
||||
$this->assertStringContainsString('<title>Terms & conditions - ' . e(config('app.name')) . '</title>', $html);
|
||||
$this->assertStringContainsString('<meta property="og:title" content="Terms & conditions - ' . e(config('app.name')) . '">', $html);
|
||||
$this->assertStringContainsString('<meta property="og:site_name"', $html);
|
||||
$this->assertStringContainsString('<meta property="og:url" content="' . url()->current() . '">', $html);
|
||||
$this->assertStringNotContainsString('&amp;', $html);
|
||||
$this->assertStringNotContainsString('name="og:', $html);
|
||||
$this->assertStringNotContainsString('og:image', $html, 'no preview image without a file');
|
||||
}
|
||||
|
||||
public function test_the_preview_image_comes_from_the_platform_folder_then_the_theme(): void
|
||||
{
|
||||
File::put("{$this->public}/theme-preview.png", 'png');
|
||||
config(['themes.themes.' . theme_id() . '.logos.og_image' => 'theme-preview.png']);
|
||||
$this->assertStringContainsString('<meta property="og:image" content="' . asset('theme-preview.png') . '">', $this->renderHead());
|
||||
|
||||
File::put("{$this->root}/public/og-image.png", 'png');
|
||||
$this->artisan('platform:link')->assertExitCode(0);
|
||||
$html = $this->renderHead();
|
||||
$this->assertStringContainsString('<meta property="og:image" content="' . asset('platform/og-image.png') . '">', $html);
|
||||
$this->assertStringContainsString('<meta name="twitter:card" content="summary_large_image">', $html);
|
||||
}
|
||||
|
||||
public function test_optional_icons_are_linked_only_when_present(): void
|
||||
{
|
||||
$html = $this->renderHead();
|
||||
$this->assertStringNotContainsString('favicon.svg', $html);
|
||||
$this->assertStringNotContainsString('rel="manifest"', $html);
|
||||
$this->assertStringContainsString('favicon-32x32.png', $html, 'the standard icons are always linked');
|
||||
|
||||
File::put("{$this->root}/public/favicon.svg", '<svg/>');
|
||||
File::put("{$this->root}/public/site.webmanifest", '{}');
|
||||
File::put("{$this->public}/favicon-96x96.png", 'png');
|
||||
$this->artisan('platform:link')->assertExitCode(0);
|
||||
|
||||
$html = $this->renderHead();
|
||||
$this->assertStringContainsString('<link rel="icon" type="image/svg+xml" href="' . asset('platform/favicon.svg') . '">', $html);
|
||||
$this->assertStringContainsString('<link rel="manifest" href="' . asset('platform/site.webmanifest') . '">', $html);
|
||||
$this->assertStringContainsString('<link rel="icon" type="image/png" sizes="96x96" href="' . asset('favicon-96x96.png') . '">', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uuro's branch added onQueue('high') to this notification (2025-12-15), which is not a queued notification:
|
||||
* every admin or bank password reset then failed with "Call to undefined method ...::onQueue()".
|
||||
*/
|
||||
public function test_the_admin_and_bank_password_reset_mail_can_be_built(): void
|
||||
{
|
||||
$notification = new NonUserPasswordResetNotification('https://example.test/reset/abc', new Admin(['name' => 'admin-test']));
|
||||
|
||||
$this->assertStringContainsString('https://example.test/reset/abc', (string) $notification->toMail(new Admin(['name' => 'admin-test']))->render());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* platform:init creates the platform folder, platform:override copies a template into it with a fingerprint of
|
||||
* its original, platform:check reports overrides that went stale. Each test has its own folder and public/.
|
||||
*/
|
||||
class PlatformToolsTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private string $public;
|
||||
private string $originalPublic;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$base = sys_get_temp_dir() . '/platform-tools-test-' . uniqid();
|
||||
$this->root = "{$base}/platform";
|
||||
$this->public = "{$base}/public";
|
||||
File::makeDirectory($this->public, 0755, true);
|
||||
$this->originalPublic = public_path();
|
||||
$this->app->usePublicPath($this->public);
|
||||
config(['app.platform_path' => $this->root]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->app->usePublicPath($this->originalPublic);
|
||||
File::deleteDirectory(dirname($this->root));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function putFile(string $path, string $content): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname("{$this->root}/{$path}"));
|
||||
File::put("{$this->root}/{$path}", $content);
|
||||
}
|
||||
|
||||
public function test_init_creates_the_folder_and_never_overwrites(): void
|
||||
{
|
||||
$this->artisan('platform:init')->assertExitCode(0);
|
||||
$this->assertFileExists("{$this->root}/README.md");
|
||||
$this->assertDirectoryExists("{$this->root}/lang");
|
||||
$this->assertFileDoesNotExist("{$this->root}/config.php");
|
||||
|
||||
$this->artisan('platform:init', ['--from-current' => true])->assertExitCode(0);
|
||||
$name = config('app.timebank_config');
|
||||
$this->assertFileEquals(config_path("{$name}.php"), "{$this->root}/config.php");
|
||||
$theme = require "{$this->root}/theme.php";
|
||||
$this->assertSame(theme_id(), $theme['id']);
|
||||
$this->assertSame(theme('colors'), $theme['colors']);
|
||||
|
||||
File::put("{$this->root}/config.php", '<?php return ["mine" => true];');
|
||||
$this->artisan('platform:init', ['--from-current' => true])->expectsOutputToContain('kept')->assertExitCode(0);
|
||||
$this->assertSame('<?php return ["mine" => true];', File::get("{$this->root}/config.php"));
|
||||
}
|
||||
|
||||
public function test_override_copies_a_template_with_the_fingerprint_of_its_original(): void
|
||||
{
|
||||
$this->artisan('platform:override', ['view' => 'emails.layouts.html'])->assertExitCode(0);
|
||||
$copy = File::get("{$this->root}/views/emails/layouts/html.blade.php");
|
||||
$original = resource_path('views/emails/layouts/html.blade.php');
|
||||
$this->assertSame('{{-- platform:override of resources/views/emails/layouts/html.blade.php sha1:' . sha1_file($original) . " --}}\n" . File::get($original), $copy);
|
||||
|
||||
$this->artisan('platform:override', ['view' => 'mail::html.panel'])->assertExitCode(0);
|
||||
$this->assertFileExists("{$this->root}/views/mail/html/panel.blade.php");
|
||||
|
||||
$this->artisan('platform:override', ['view' => 'wirechat::components.divider'])->assertExitCode(0);
|
||||
$this->assertFileExists("{$this->root}/views/vendor/wirechat/components/divider.blade.php");
|
||||
|
||||
$this->artisan('platform:override', ['view' => 'emails.layouts.html'])->expectsOutputToContain('exists already')->assertExitCode(1);
|
||||
$this->artisan('platform:override', ['view' => 'emails.no-such-template'])->expectsOutputToContain('No template')->assertExitCode(1);
|
||||
}
|
||||
|
||||
public function test_check_without_a_folder(): void
|
||||
{
|
||||
$this->artisan('platform:check')->expectsOutputToContain('No platform folder')->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_check_reports_stale_overrides(): void
|
||||
{
|
||||
$base = json_decode(File::get(lang_path('en.json')), true);
|
||||
$existing = array_key_first($base);
|
||||
$this->putFile('lang/en.json', json_encode([
|
||||
$existing => 'Changed',
|
||||
'A sentence the code reworded long ago' => 'Old override',
|
||||
'Own text in an own template' => 'Platform text',
|
||||
]));
|
||||
$this->putFile('views/own-block.blade.php', "{{ __('Own text in an own template') }}");
|
||||
$this->putFile('lang/en/messages.php', "<?php\n\nreturn ['no_such_message_key' => 'x'];\n");
|
||||
|
||||
$this->artisan('platform:override', ['view' => 'emails.layouts.html'])->assertExitCode(0);
|
||||
$this->artisan('platform:override', ['view' => 'mail::html.panel'])->assertExitCode(0);
|
||||
// The panel's original "changed" after the copy: simulate with another fingerprint
|
||||
$panel = "{$this->root}/views/mail/html/panel.blade.php";
|
||||
File::put($panel, preg_replace('/sha1:[0-9a-f]{40}/', 'sha1:' . str_repeat('0', 40), File::get($panel)));
|
||||
|
||||
$this->artisan('platform:check')
|
||||
->expectsOutputToContain('A sentence the code reworded long ago')
|
||||
->expectsOutputToContain('no_such_message_key')
|
||||
->expectsOutputToContain('views/mail/html/panel.blade.php: the original changed')
|
||||
->expectsOutputToContain("views/own-block.blade.php: the platform's own template")
|
||||
->doesntExpectOutputToContain('Own text in an own template')
|
||||
->doesntExpectOutputToContain('views/emails/layouts/html.blade.php: the original changed')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_check_fails_on_invalid_json_and_warns_about_config_and_link(): void
|
||||
{
|
||||
$this->putFile('lang/nl.json', '{"broken": ');
|
||||
$this->putFile('config.php', '<?php return [];');
|
||||
$this->putFile('public/favicon.ico', 'icon');
|
||||
|
||||
$this->artisan('platform:check')
|
||||
->expectsOutputToContain('lang/nl.json is not valid JSON')
|
||||
->expectsOutputToContain('Both platform/config.php and config/')
|
||||
->expectsOutputToContain('run php artisan platform:link')
|
||||
->assertExitCode(1);
|
||||
|
||||
$this->artisan('platform:link')->assertExitCode(0);
|
||||
$this->putFile('lang/nl.json', '{}');
|
||||
$this->artisan('platform:check')->expectsOutputToContain('linked at /platform')->assertExitCode(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Category;
|
||||
use App\Models\CategoryTranslation;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Polls\Ledger;
|
||||
use App\Polls\PollBackup;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Poll definitions in post backups: exported and restored as drafts, and an existing
|
||||
* poll with votes is never overwritten (that would cascade to its votes and ledger).
|
||||
*/
|
||||
class PollBackupTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private string $file;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array', 'scout.driver' => null]);
|
||||
$this->file = storage_path('framework/testing/poll-backup-' . uniqid() . '.json');
|
||||
File::ensureDirectoryExists(dirname($this->file));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::delete($this->file);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makePollPost(string $slug, Organization $owner): Poll
|
||||
{
|
||||
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
CategoryTranslation::firstOrCreate(['category_id' => $category->id, 'locale' => 'en'], ['name' => 'Poll', 'slug' => 'poll-backup-test-' . $category->id]);
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'category_id' => $category->id, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $postId, 'locale' => 'en', 'slug' => $slug, 'title' => 'Backed up poll', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create([
|
||||
'post_id' => $postId, 'pollable_id' => $owner->id, 'pollable_type' => Organization::class,
|
||||
'status' => Poll::STATUS_OPEN, 'closes_at' => '2026-12-01 18:00:00', 'vote_scope' => Poll::VOTE_SCOPE_PER_USER,
|
||||
'anonymous' => true, 'verification' => Poll::VERIFICATION_RECEIPTS,
|
||||
'filter_by_location' => true, 'location_city_ids' => [99999],
|
||||
'target_profile_types' => [Organization::class], 'registered_until' => '2025-12-31',
|
||||
]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'First?'], 'settings' => []]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 2, 'type' => 'scale', 'required' => false,
|
||||
'label' => ['en' => 'Second?', 'nl' => 'Tweede?'], 'settings' => ['min' => 1, 'max' => 5]]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
public function test_round_trip_restores_the_definition_as_a_draft(): void
|
||||
{
|
||||
$owner = Organization::factory()->create(['name' => 'Backup Owner Org']);
|
||||
$poll = $this->makePollPost('poll-backup-round-trip', $owner);
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->artisan('posts:backup', ['--post-ids' => (string) $poll->post_id, '--exclude-media' => true, '--output' => $this->file])
|
||||
->assertSuccessful();
|
||||
|
||||
$backup = json_decode(File::get($this->file), true);
|
||||
$this->assertSame(1, $backup['meta']['counts']['polls']);
|
||||
$this->assertCount(2, $backup['posts'][0]['poll']['questions']);
|
||||
$this->assertArrayNotHasKey('responses', $backup['posts'][0]['poll']);
|
||||
|
||||
// Gone from this database...
|
||||
DB::table('post_translations')->where('post_id', $poll->post_id)->delete();
|
||||
DB::table('posts')->where('id', $poll->post_id)->delete();
|
||||
|
||||
// ...and restored
|
||||
$this->artisan('posts:restore', ['file' => $this->file, '--profile-id' => $admin->id, '--profile-type' => 'Admin'])
|
||||
->expectsConfirmation('Do you want to proceed with the restore?', 'yes')
|
||||
->assertSuccessful();
|
||||
|
||||
$postId = PostTranslation::where('slug', 'poll-backup-round-trip')->value('post_id');
|
||||
$restored = Poll::where('post_id', $postId)->sole();
|
||||
$this->assertSame(Poll::STATUS_DRAFT, $restored->status);
|
||||
$this->assertTrue($restored->pollable->is($owner));
|
||||
$this->assertSame('2026-12-01 18:00', $restored->closes_at->format('Y-m-d H:i'));
|
||||
$this->assertSame(Poll::VOTE_SCOPE_PER_USER, $restored->vote_scope);
|
||||
$this->assertTrue($restored->anonymous);
|
||||
$this->assertSame(Poll::VERIFICATION_RECEIPTS, $restored->verification);
|
||||
$this->assertSame([Organization::class], $restored->target_profile_types);
|
||||
$this->assertFalse($restored->filter_by_location); // location ids differ between databases
|
||||
$this->assertEmpty($restored->location_city_ids);
|
||||
$this->assertSame(['yes_no', 'scale'], $restored->questions()->pluck('type')->all());
|
||||
$this->assertSame('Tweede?', $restored->questions()->get()[1]->labelFor('nl'));
|
||||
$this->assertNull($restored->sealed_at);
|
||||
}
|
||||
|
||||
public function test_import_rejects_unknown_owner_types(): void
|
||||
{
|
||||
$post = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
|
||||
$poll = PollBackup::import($post, ['owner_type' => 'Illuminate\\Support\\Facades\\File', 'owner_name' => 'x', 'vote_scope' => 'nonsense', 'questions' => [['type' => 'yes_no']]]);
|
||||
|
||||
$this->assertNull($poll->pollable_type);
|
||||
$this->assertSame(Poll::VOTE_SCOPE_PER_PROFILE, $poll->vote_scope);
|
||||
$this->assertSame(1, $poll->questions()->count());
|
||||
}
|
||||
|
||||
public function test_overwrite_never_deletes_a_poll_with_votes(): void
|
||||
{
|
||||
$poll = $this->makePollPost('poll-backup-protected', Organization::factory()->create());
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->artisan('posts:backup', ['--post-ids' => (string) $poll->post_id, '--exclude-media' => true, '--output' => $this->file])
|
||||
->assertSuccessful();
|
||||
|
||||
// Someone votes after the backup was made
|
||||
$ledger = new Ledger();
|
||||
DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'r')));
|
||||
$this->assertTrue(PollBackup::protectsSlugs(['poll-backup-protected']));
|
||||
|
||||
$this->artisan('posts:restore', ['file' => $this->file, '--profile-id' => $admin->id, '--profile-type' => 'Admin'])
|
||||
->expectsConfirmation('Do you want to proceed with the restore?', 'yes')
|
||||
->expectsChoice('What would you like to do?', 'overwrite', [
|
||||
'skip', 'overwrite', 'skip_all', 'overwrite_all',
|
||||
'Skip this post', 'Overwrite existing post(s)', 'Skip all duplicates', 'Overwrite all duplicates',
|
||||
])
|
||||
->expectsOutputToContain('Not overwritten: the existing post has a poll with votes.')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertNotNull(Poll::find($poll->id));
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
$this->assertSame(1, PostTranslation::where('slug', 'poll-backup-protected')->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollResultsExportController;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\ResultsCsv;
|
||||
use App\Polls\Types\DateTimeSlotsType;
|
||||
use App\Polls\Types\MultiChoiceType;
|
||||
use App\Polls\Types\RankingType;
|
||||
use App\Polls\Types\ScaleType;
|
||||
use App\Polls\Types\SingleChoiceType;
|
||||
use App\Polls\Types\TextType;
|
||||
use App\Polls\Types\YesNoConditionalType;
|
||||
use App\Polls\Types\YesNoType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CSV export of poll results.
|
||||
*/
|
||||
class PollExportTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
app()->setLocale('en');
|
||||
}
|
||||
|
||||
private function opts(array $labels): array
|
||||
{
|
||||
return array_map(fn ($key, $label) => ['key' => $key, 'label' => ['en' => $label]], array_keys($labels), $labels);
|
||||
}
|
||||
|
||||
public function test_answers_as_text_per_type(): void
|
||||
{
|
||||
$this->assertSame('Yes', (new YesNoType())->answerToText('yes', null, []));
|
||||
$this->assertSame('Maybe, if...: after 18:00', (new YesNoConditionalType())->answerToText('maybe_if', 'after 18:00', []));
|
||||
|
||||
$options = ['options' => $this->opts(['a' => 'Garden', 'b' => 'Repair café']), 'allow_other' => true];
|
||||
$this->assertSame('Repair café', (new SingleChoiceType())->answerToText('b', null, $options));
|
||||
$this->assertSame('Other: Bike tour', (new SingleChoiceType())->answerToText('__other', 'Bike tour', $options));
|
||||
$this->assertSame('Garden; Other: Bike tour', (new MultiChoiceType())->answerToText(['a', '__other'], 'Bike tour', $options));
|
||||
$this->assertSame('1. Repair café; 2. Garden', (new RankingType())->answerToText(['b', 'a'], null, $options));
|
||||
|
||||
$slots = ['allow_if_needed' => true, 'slots' => [['key' => 's1', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null]]];
|
||||
$this->assertSame('Monday 2 November 2026, 10:00: If needed', (new DateTimeSlotsType())->answerToText(['s1' => 'if_needed'], null, $slots, 'en'));
|
||||
|
||||
$this->assertSame('4', (new ScaleType())->answerToText(4, null, ['min' => 1, 'max' => 5]));
|
||||
$this->assertSame('Free text', (new TextType())->answerToText(null, 'Free text', []));
|
||||
}
|
||||
|
||||
public function test_formula_injection_is_neutralised(): void
|
||||
{
|
||||
$this->assertSame("'=HYPERLINK(\"x\")", ResultsCsv::safeCell('=HYPERLINK("x")'));
|
||||
$this->assertSame("'+1", ResultsCsv::safeCell('+1'));
|
||||
$this->assertSame("'@SUM(A1)", ResultsCsv::safeCell('@SUM(A1)'));
|
||||
$this->assertSame('Normal answer', ResultsCsv::safeCell('Normal answer'));
|
||||
$this->assertSame('', ResultsCsv::safeCell(''));
|
||||
}
|
||||
|
||||
/** A poll with a single choice and a text question, and two votes. */
|
||||
private function pollWithVotes(bool $anonymous): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN, 'anonymous' => $anonymous]);
|
||||
$choice = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'single_choice', 'label' => ['en' => 'Favourite'],
|
||||
'settings' => ['options' => $this->opts(['a' => 'Garden', 'b' => 'Repair café'])],
|
||||
]);
|
||||
$text = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 2, 'type' => 'text', 'required' => false, 'label' => ['en' => 'Remarks'],
|
||||
'settings' => ['multiline' => true, 'max_length' => 100],
|
||||
]);
|
||||
|
||||
$alice = User::factory()->create(['name' => 'Alice Voter']);
|
||||
$this->vote($poll, $alice, $alice, [$choice->id => ['value' => 'a', 'text' => null], $text->id => ['value' => null, 'text' => '=cmd()']], '2026-10-01 10:00:00');
|
||||
$organization = Organization::factory()->create(['name' => 'Garden Club']);
|
||||
$bob = User::factory()->create(['name' => 'Bob Manager']);
|
||||
$this->vote($poll, $organization, $bob, [$choice->id => ['value' => 'b', 'text' => null]], '2026-10-02 11:00:00');
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, Model $profile, User $user, array $answers, string $at): void
|
||||
{
|
||||
$response = PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $profile->id, 'voter_type' => get_class($profile), 'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'submitted_at' => $at,
|
||||
]);
|
||||
$response->forceFill(['created_at' => $at, 'updated_at' => $at])->saveQuietly();
|
||||
foreach ($answers as $questionId => $answer) {
|
||||
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $questionId] + $answer);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_named_export_has_voters_and_answers(): void
|
||||
{
|
||||
$csv = new ResultsCsv($this->pollWithVotes(false), 'en');
|
||||
|
||||
$this->assertSame(['Voted', 'Changed', 'Profile type', 'Profile', 'Submitted by', 'Favourite', 'Remarks'], $csv->header());
|
||||
$this->assertSame([
|
||||
['2026-10-01 10:00', '', 'User', 'Alice Voter', 'Alice Voter', 'Garden', "'=cmd()"],
|
||||
['2026-10-02 11:00', '', 'Organization', 'Garden Club', 'Bob Manager', 'Repair café', ''],
|
||||
], iterator_to_array($csv->rows(), false));
|
||||
}
|
||||
|
||||
public function test_anonymous_export_leaves_out_who_and_when(): void
|
||||
{
|
||||
$csv = new ResultsCsv($this->pollWithVotes(true), 'en');
|
||||
|
||||
$this->assertSame(['Favourite', 'Remarks'], $csv->header());
|
||||
$rows = iterator_to_array($csv->rows(), false);
|
||||
$this->assertCount(2, $rows);
|
||||
sort($rows);
|
||||
$this->assertSame([['Garden', "'=cmd()"], ['Repair café', '']], $rows);
|
||||
$this->assertStringNotContainsString('Alice', json_encode($rows));
|
||||
}
|
||||
|
||||
public function test_download_requires_admin_with_poll_export_permission(): void
|
||||
{
|
||||
$poll = $this->pollWithVotes(false);
|
||||
|
||||
// A User profile
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->fail('A user profile could download the results');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
|
||||
// An admin who may only view polls (or manage posts) may not see who voted
|
||||
$webUser = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($webUser);
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\export-role", 'guard_name' => 'web']);
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'view polls', 'guard_name' => 'web']));
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage posts', 'guard_name' => 'web']));
|
||||
$webUser->assignRole($role);
|
||||
$this->actingAs($webUser, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->fail('An admin with only view polls could download the results');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
|
||||
// With the export permission the file comes
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'export polls', 'guard_name' => 'web']));
|
||||
app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$webUser->load('roles');
|
||||
|
||||
$response = (new PollResultsExportController())($poll);
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$body = ob_get_clean();
|
||||
|
||||
$this->assertStringStartsWith("\xEF\xBB\xBF", $body);
|
||||
$this->assertStringContainsString('Garden Club', $body);
|
||||
$this->assertStringContainsString('attachment; filename=poll-' . $poll->id . '-results-', $response->headers->get('Content-Disposition'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Polls\PollForm;
|
||||
use App\Mail\PollReceiptMail;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The voting form, rendered as a real Livewire component.
|
||||
*/
|
||||
class PollFormTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
/** A poll with a yes/no/if question and an optional open text question. */
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => Admin::class,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no_conditional', 'required' => true,
|
||||
'label' => ['en' => 'Shall we meet monthly?'], 'settings' => [],
|
||||
]);
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 2, 'type' => 'text', 'required' => false,
|
||||
'label' => ['en' => 'Anything else?'], 'settings' => ['multiline' => true, 'max_length' => 200],
|
||||
]);
|
||||
|
||||
return $poll->fresh();
|
||||
}
|
||||
|
||||
private function actAs(Model $profile, User $user): void
|
||||
{
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]);
|
||||
}
|
||||
|
||||
private function questionIds(Poll $poll): array
|
||||
{
|
||||
return $poll->questions()->pluck('id')->all();
|
||||
}
|
||||
|
||||
public function test_targeted_profile_sees_the_form(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_FORM)
|
||||
->assertSee('Shall we meet monthly?')
|
||||
->assertSee('Anything else?')
|
||||
->assertSee('Submit vote');
|
||||
}
|
||||
|
||||
public function test_untargeted_profile_and_guest_see_nothing(): void
|
||||
{
|
||||
$poll = $this->makePoll(['target_profile_types' => [Organization::class]]);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_HIDDEN)
|
||||
->assertDontSee('Shall we meet monthly?');
|
||||
|
||||
auth('web')->logout();
|
||||
session()->forget(['activeProfileType', 'activeProfileId']);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_HIDDEN);
|
||||
}
|
||||
|
||||
public function test_submit_stores_vote_ledger_and_shows_receipt_once(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo, $text] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'maybe_if')
|
||||
->set("answers.$yesNo.text", 'on a weekday')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED)
|
||||
->assertSee('Thank you, your vote has been recorded.');
|
||||
|
||||
$receipt = $component->get('receiptCode');
|
||||
$this->assertNotNull($receipt);
|
||||
$component->assertSee($receipt);
|
||||
// Copy button: the code reaches Alpine (@js is not compiled inside component attributes)
|
||||
$component->assertSeeHtml('code: '.\Illuminate\Support\Js::from($receipt))
|
||||
->assertSeeHtml('x-on:click="copy()"')
|
||||
->assertDontSeeHtml('@js(');
|
||||
// The dedicated page has the verify link at the bottom, not in the receipt block
|
||||
$component->assertDontSee('Verify this poll');
|
||||
|
||||
$response = PollResponse::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame(User::class, $response->voter_type);
|
||||
$this->assertSame($user->id, $response->user_id);
|
||||
$this->assertNotNull($response->submitted_at);
|
||||
|
||||
// The optional, empty text question is not stored
|
||||
$this->assertSame(1, $response->answers()->count());
|
||||
$this->assertSame('on a weekday', $response->answers()->first()->text);
|
||||
|
||||
// Ledger: one cast entry, linked by sequence, matching the receipt, no identity
|
||||
$entry = PollLedgerEntry::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame(PollLedgerEntry::CAST, $entry->entry_type);
|
||||
$this->assertSame($entry->sequence, $response->ledger_sequence);
|
||||
$this->assertSame((new Ledger())->fingerprint($receipt), $entry->fingerprint);
|
||||
$this->assertStringNotContainsString((string) $user->email, json_encode($entry->getAttributes()));
|
||||
$this->assertSame('counted', (new Ledger())->lookupReceipt($poll, $receipt)['status']);
|
||||
$this->assertSame([
|
||||
['question_id' => $yesNo, 'value' => 'maybe_if', 'text_sha256' => hash('sha256', 'on a weekday')],
|
||||
['question_id' => $text, 'value' => null, 'text_sha256' => null],
|
||||
], $entry->payload['answers']);
|
||||
// The text itself is only in poll_answers, never in the public ledger
|
||||
$this->assertStringNotContainsString('on a weekday', json_encode($entry->getAttributes()));
|
||||
|
||||
// Reloading: the post page shows the vote, without the receipt
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->assertSet('receiptCode', null)
|
||||
->assertSee('You voted on')
|
||||
->assertDontSee($receipt)
|
||||
->assertDontSee('Change vote');
|
||||
}
|
||||
|
||||
public function test_main_page_card_disappears_after_voting(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card'])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED)
|
||||
// The card has no verify link of its own, so the receipt block keeps it
|
||||
->assertSee('Verify this poll');
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card'])
|
||||
->assertSet('state', PollForm::STATE_HIDDEN)
|
||||
->assertDontSee('Shall we meet monthly?');
|
||||
}
|
||||
|
||||
public function test_validation_errors_name_the_question(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->call('submit')
|
||||
->assertHasErrors(["answers.$yesNo.value" => 'required'])
|
||||
->assertSee('Shall we meet monthly?')
|
||||
->assertSet('state', PollForm::STATE_FORM);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'maybe_if')
|
||||
->call('submit')
|
||||
->assertHasErrors(["answers.$yesNo.text"])
|
||||
->assertSeeHtmlInOrder(['Please fill in the condition.'])
|
||||
->assertDontSee('maybe_if field')
|
||||
->tap(fn ($component) => $this->assertSame(1, substr_count($component->html(), 'Please fill in the condition.')));
|
||||
|
||||
$this->assertSame(0, PollResponse::where('poll_id', $poll->id)->count());
|
||||
$this->assertSame(0, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_date_slot_errors_do_not_show_the_slot_keys(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$question = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 3, 'type' => 'date_time_slots', 'required' => true,
|
||||
'label' => ['en' => 'When can you come?'],
|
||||
'settings' => ['allow_if_needed' => true, 'slots' => [
|
||||
['key' => 'k1slot', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null],
|
||||
['key' => 'k2slot', 'starts_at' => '2026-11-03 10:00', 'ends_at' => null],
|
||||
]],
|
||||
]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
// One date answered: only the other date gets a message, in words
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->set("answers.{$question->id}.value.k1slot", 'yes')
|
||||
->call('submit')
|
||||
->assertHasErrors(["answers.{$question->id}.value.k2slot"])
|
||||
->assertSee('Choose an answer for this date.')
|
||||
->assertDontSee('k2slot field');
|
||||
}
|
||||
|
||||
public function test_second_manager_cannot_vote_again_for_the_organization(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
// Both managers open the form before either has voted
|
||||
$this->actAs($organization, $manager2);
|
||||
$late = Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_FORM);
|
||||
|
||||
$this->actAs($organization, $manager1);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED);
|
||||
|
||||
$this->actAs($organization, $manager2);
|
||||
$late->set("answers.$yesNo.value", 'no')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->assertSet('receiptCode', null);
|
||||
|
||||
$response = PollResponse::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame(Organization::class, $response->voter_type);
|
||||
$this->assertSame($manager1->id, $response->user_id);
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_per_user_poll_lets_each_manager_vote(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
foreach (User::factory()->count(2)->create() as $manager) {
|
||||
$this->actAs($organization, $manager);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED);
|
||||
}
|
||||
|
||||
$this->assertSame(2, PollResponse::where('poll_id', $poll->id)->where('voter_type', Organization::class)->count());
|
||||
}
|
||||
|
||||
public function test_changing_a_vote(): void
|
||||
{
|
||||
$poll = $this->makePoll(['allow_vote_change' => true]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$first = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->get('receiptCode');
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->assertSee('Change vote')
|
||||
->call('changeVote')
|
||||
->assertSet('state', PollForm::STATE_FORM)
|
||||
->assertSet("answers.$yesNo.value", 'yes') // prefilled
|
||||
->set("answers.$yesNo.value", 'no')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED);
|
||||
$second = $component->get('receiptCode');
|
||||
|
||||
$this->assertNotSame($first, $second);
|
||||
$response = PollResponse::where('poll_id', $poll->id)->sole();
|
||||
$this->assertSame('no', $response->answers()->sole()->value);
|
||||
|
||||
$ledger = new Ledger();
|
||||
$change = PollLedgerEntry::where('poll_id', $poll->id)->where('sequence', 2)->sole();
|
||||
$this->assertSame(PollLedgerEntry::CHANGE, $change->entry_type);
|
||||
$this->assertSame(1, $change->refers_to_sequence);
|
||||
$this->assertSame(2, $response->ledger_sequence);
|
||||
$this->assertSame('replaced', $ledger->lookupReceipt($poll, $first)['status']);
|
||||
$this->assertSame('counted', $ledger->lookupReceipt($poll, $second)['status']);
|
||||
$this->assertTrue($ledger->verifyChain($poll)['valid']);
|
||||
}
|
||||
|
||||
public function test_change_vote_is_refused_when_not_allowed(): void
|
||||
{
|
||||
$poll = $this->makePoll(['allow_vote_change' => false]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit');
|
||||
|
||||
// Calling the actions directly, as a tampered request would
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->call('changeVote')
|
||||
->assertSet('state', PollForm::STATE_VOTED)
|
||||
->set("answers.$yesNo.value", 'no')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_VOTED);
|
||||
|
||||
$this->assertSame('yes', PollResponse::where('poll_id', $poll->id)->sole()->answers()->sole()->value);
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_poll_closing_while_the_form_is_open_refuses_the_vote(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id]);
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
|
||||
$component->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_HIDDEN);
|
||||
$this->assertSame(0, PollResponse::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_no_receipt_when_verification_is_off(): void
|
||||
{
|
||||
$poll = $this->makePoll(['verification' => Poll::VERIFICATION_OFF]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSet('state', PollForm::STATE_SUBMITTED)
|
||||
->assertSet('receiptCode', null)
|
||||
->assertDontSee('Your receipt code');
|
||||
|
||||
// The ledger is still written, so verification can be enabled later
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->count());
|
||||
}
|
||||
|
||||
public function test_results_shown_after_vote_when_configured(): void
|
||||
{
|
||||
$poll = $this->makePoll(['results_visibility' => Poll::RESULTS_AFTER_VOTE]);
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertDontSee('Results so far')
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertSee('Results so far')
|
||||
->assertSee('1 response');
|
||||
|
||||
$hidden = $this->makePoll(['results_visibility' => Poll::RESULTS_AFTER_CLOSE]);
|
||||
[$hiddenYesNo] = $this->questionIds($hidden);
|
||||
Livewire::test(PollForm::class, ['pollId' => $hidden->id])
|
||||
->set("answers.$hiddenYesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->assertDontSee('Results so far');
|
||||
}
|
||||
|
||||
public function test_receipt_email_is_sent_immediately_not_queued(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$poll = $this->makePoll();
|
||||
[$yesNo] = $this->questionIds($poll);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
|
||||
$component = Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->set("answers.$yesNo.value", 'yes')
|
||||
->call('submit')
|
||||
->call('emailReceipt')
|
||||
->assertSet('receiptEmailed', true)
|
||||
->assertSee('Sent to your email address.');
|
||||
|
||||
$receipt = $component->get('receiptCode');
|
||||
Mail::assertSent(PollReceiptMail::class, fn ($mail) => $mail->hasTo($user->email) && $mail->receiptCode === $receipt);
|
||||
Mail::assertNothingQueued();
|
||||
|
||||
// Only once per submit
|
||||
$component->call('emailReceipt');
|
||||
Mail::assertSent(PollReceiptMail::class, 1);
|
||||
}
|
||||
|
||||
public function test_receipt_mail_renders(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$html = (new PollReceiptMail($poll, $user, 'ABCDE-FGHJK-MNPQR-STUVW'))->render();
|
||||
|
||||
$this->assertStringContainsString('ABCDE-FGHJK-MNPQR-STUVW', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The append-only ballot ledger: hash chain, tamper detection, changes,
|
||||
* withdrawals and receipts.
|
||||
*/
|
||||
class PollLedgerTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private Ledger $ledger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
$this->ledger = new Ledger();
|
||||
}
|
||||
|
||||
private function makePoll(): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => Admin::class,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN]);
|
||||
}
|
||||
|
||||
private function cast(Poll $poll, array $normalized, ?string $receipt = null, string $type = PollLedgerEntry::CAST, ?int $refersTo = null): PollLedgerEntry
|
||||
{
|
||||
return DB::transaction(fn () => $this->ledger->append(
|
||||
$poll,
|
||||
$type,
|
||||
$this->ledger->ballotPayload($normalized),
|
||||
$this->ledger->fingerprint($receipt ?? $this->ledger->newReceiptCode()),
|
||||
$refersTo
|
||||
));
|
||||
}
|
||||
|
||||
public function test_canonical_format_is_stable(): void
|
||||
{
|
||||
// Golden value: the browser-side verification must produce exactly this hash.
|
||||
// Changing it breaks every existing chain, see Ledger::FORMAT_VERSION.
|
||||
$entry = [
|
||||
'poll_id' => 7,
|
||||
'sequence' => 1,
|
||||
'entry_type' => 'cast',
|
||||
'fingerprint' => str_repeat('a', 64),
|
||||
'refers_to_sequence' => null,
|
||||
'payload' => ['answers' => [
|
||||
['question_id' => 3, 'value' => ['zz' => 'yes', 'ab' => 'no'], 'text' => null],
|
||||
['question_id' => 5, 'value' => null, 'text' => 'Café / "quoted" ü'],
|
||||
]],
|
||||
'recorded_at' => '2026-10-10T17:00:00Z',
|
||||
'previous_hash' => Ledger::GENESIS_HASH,
|
||||
];
|
||||
|
||||
$expectedCanonical = '{"entry_type":"cast","fingerprint":"' . str_repeat('a', 64) . '","payload":{"answers":['
|
||||
. '{"question_id":3,"text":null,"value":{"ab":"no","zz":"yes"}},'
|
||||
. '{"question_id":5,"text":"Café / \"quoted\" ü","value":null}]},'
|
||||
. '"poll_id":7,"previous_hash":"' . Ledger::GENESIS_HASH . '","recorded_at":"2026-10-10T17:00:00Z",'
|
||||
. '"refers_to_sequence":null,"sequence":1,"v":1}';
|
||||
|
||||
$this->assertSame(hash('sha256', $expectedCanonical), $this->ledger->hash($entry));
|
||||
}
|
||||
|
||||
public function test_ballot_payload_orders_answers_by_question(): void
|
||||
{
|
||||
$payload = $this->ledger->ballotPayload([
|
||||
9 => ['value' => 'b', 'text' => null],
|
||||
2 => ['value' => ['x', 'y'], 'text' => null],
|
||||
]);
|
||||
|
||||
$this->assertSame([2, 9], array_column($payload['answers'], 'question_id'));
|
||||
// Free text is committed to by its hash only
|
||||
$payload = $this->ledger->ballotPayload([3 => ['value' => null, 'text' => 'Ask the neighbours']]);
|
||||
$this->assertSame(['question_id' => 3, 'value' => null, 'text_sha256' => hash('sha256', 'Ask the neighbours')], $payload['answers'][0]);
|
||||
$this->assertStringNotContainsString('neighbours', json_encode($payload));
|
||||
}
|
||||
|
||||
public function test_chain_links_entries_and_verifies(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$first = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$second = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
$this->assertSame(1, $first->sequence);
|
||||
$this->assertSame(Ledger::GENESIS_HASH, $first->previous_hash);
|
||||
$this->assertSame($first->hash, $second->previous_hash);
|
||||
$this->assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/', $second->recorded_at);
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertSame(2, $result['entries']);
|
||||
$this->assertSame($second->hash, $result['head']);
|
||||
}
|
||||
|
||||
public function test_entries_are_recorded_with_the_day_only(): void
|
||||
{
|
||||
config(['app.timezone' => 'Europe/Paris']);
|
||||
\Illuminate\Support\Carbon::setTestNow(\Illuminate\Support\Carbon::parse('2026-10-11 14:37:12', 'Europe/Paris'));
|
||||
$ledger = new Ledger();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
$first = DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'a')));
|
||||
\Illuminate\Support\Carbon::setTestNow(\Illuminate\Support\Carbon::parse('2026-10-11 23:59:59', 'Europe/Paris'));
|
||||
$second = DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'b')));
|
||||
\Illuminate\Support\Carbon::setTestNow();
|
||||
|
||||
// Start of the local day, stored in UTC: no hour, minute or second of the vote
|
||||
$this->assertSame('2026-10-10T22:00:00Z', $first->recorded_at);
|
||||
$this->assertSame($first->recorded_at, $second->recorded_at);
|
||||
$this->assertTrue($ledger->verifyChain($poll)['valid']);
|
||||
}
|
||||
|
||||
public function test_chains_are_per_poll(): void
|
||||
{
|
||||
$pollA = $this->makePoll();
|
||||
$pollB = $this->makePoll();
|
||||
$this->cast($pollA, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
|
||||
$this->assertSame(1, $this->cast($pollB, [1 => ['value' => 'yes', 'text' => null]])->sequence);
|
||||
}
|
||||
|
||||
public function test_tampering_with_a_payload_breaks_the_chain(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$target = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
// Bypasses the model, as a direct DB edit would (the test DB user still has UPDATE)
|
||||
DB::table('poll_ledger')->where('id', $target->id)
|
||||
->update(['payload' => json_encode(['answers' => [['question_id' => 1, 'value' => 'yes', 'text' => null]]])]);
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame(2, $result['broken_at']);
|
||||
}
|
||||
|
||||
public function test_removing_an_entry_breaks_the_chain(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$middle = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
DB::table('poll_ledger')->where('id', $middle->id)->delete();
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame(3, $result['broken_at']);
|
||||
}
|
||||
|
||||
public function test_rehashing_a_tampered_entry_still_breaks_the_next_link(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$target = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
|
||||
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
|
||||
|
||||
// An attacker who also recomputes the edited entry's own hash
|
||||
$attributes = $target->getAttributes();
|
||||
$attributes['payload'] = ['answers' => [['question_id' => 1, 'value' => 'no', 'text' => null]]];
|
||||
DB::table('poll_ledger')->where('id', $target->id)->update([
|
||||
'payload' => json_encode($attributes['payload']),
|
||||
'hash' => $this->ledger->hash($attributes),
|
||||
]);
|
||||
|
||||
$result = $this->ledger->verifyChain($poll);
|
||||
$this->assertFalse($result['valid']);
|
||||
$this->assertSame(2, $result['broken_at']);
|
||||
}
|
||||
|
||||
public function test_entries_cannot_be_changed_or_deleted_through_the_model(): void
|
||||
{
|
||||
$entry = $this->cast($this->makePoll(), [1 => ['value' => 'yes', 'text' => null]]);
|
||||
|
||||
try {
|
||||
$entry->update(['payload' => null]);
|
||||
$this->fail('Update was allowed');
|
||||
} catch (LogicException $e) {
|
||||
$this->assertStringContainsString('cannot be changed', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$entry->delete();
|
||||
}
|
||||
|
||||
public function test_append_requires_a_transaction(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
// DatabaseTransactions wraps the test in one; leave it to simulate a bare call
|
||||
$level = DB::transactionLevel();
|
||||
for ($i = 0; $i < $level; $i++) {
|
||||
DB::rollBack();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->expectException(LogicException::class);
|
||||
$this->ledger->append($poll, PollLedgerEntry::CAST);
|
||||
} finally {
|
||||
for ($i = 0; $i < $level; $i++) {
|
||||
DB::beginTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_changes_and_withdrawals_decide_which_ballots_count(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$a = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]], 'AAAAA-AAAAA-AAAAA-AAAAA');
|
||||
$b = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]], 'BBBBB-BBBBB-BBBBB-BBBBB');
|
||||
$c = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]], 'CCCCC-CCCCC-CCCCC-CCCCC');
|
||||
|
||||
// Voter A changes their vote, voter C's profile is deleted
|
||||
$a2 = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]], 'DDDDD-DDDDD-DDDDD-DDDDD', PollLedgerEntry::CHANGE, $a->sequence);
|
||||
DB::transaction(fn () => $this->ledger->append($poll, PollLedgerEntry::WITHDRAW, ['reason' => 'profile_deleted'], null, $c->sequence));
|
||||
|
||||
$this->assertSame([$b->sequence, $a2->sequence], $this->ledger->activeBallots($poll)->pluck('sequence')->all());
|
||||
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
|
||||
|
||||
$this->assertSame('replaced', $this->ledger->lookupReceipt($poll, 'AAAAA-AAAAA-AAAAA-AAAAA')['status']);
|
||||
$this->assertSame('counted', $this->ledger->lookupReceipt($poll, 'bbbbb bbbbb bbbbb bbbbb')['status']);
|
||||
$this->assertSame('withdrawn', $this->ledger->lookupReceipt($poll, 'CCCCCCCCCCCCCCCCCCCC')['status']);
|
||||
$this->assertSame('counted', $this->ledger->lookupReceipt($poll, 'DDDDD-DDDDD-DDDDD-DDDDD')['status']);
|
||||
$this->assertNull($this->ledger->lookupReceipt($poll, 'EEEEE-EEEEE-EEEEE-EEEEE'));
|
||||
// A receipt only works for its own poll
|
||||
$this->assertNull($this->ledger->lookupReceipt($this->makePoll(), 'BBBBB-BBBBB-BBBBB-BBBBB'));
|
||||
}
|
||||
|
||||
public function test_receipt_codes(): void
|
||||
{
|
||||
$code = $this->ledger->newReceiptCode();
|
||||
|
||||
$this->assertMatchesRegularExpression('/^[2-9A-HJKMNP-Z]{5}(-[2-9A-HJKMNP-Z]{5}){3}$/', $code);
|
||||
$this->assertNotSame($code, $this->ledger->newReceiptCode());
|
||||
$this->assertSame($this->ledger->fingerprint($code), $this->ledger->fingerprint(strtolower(str_replace('-', ' ', $code))));
|
||||
}
|
||||
|
||||
public function test_payload_survives_the_json_column_round_trip(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->cast($poll, [
|
||||
4 => ['value' => ['k2' => 'yes', 'a1' => 'if_needed'], 'text' => null],
|
||||
2 => ['value' => ['b', 'a', 'c'], 'text' => null],
|
||||
3 => ['value' => 4, 'text' => null],
|
||||
8 => ['value' => null, 'text' => "Two\nlines – with ünicode \u{2028} and / slash"],
|
||||
9 => ['value' => [], 'text' => null],
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Mail\TestNewsletterMail;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Mailing;
|
||||
use App\Models\Poll;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Poll posts as mailing content blocks: own block template, link to the poll page,
|
||||
* and the poll's state at the time the email is built.
|
||||
*/
|
||||
class PollMailingTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
private function makePollPost(array $attributes = []): Post
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
foreach (['en' => 'mailing-poll-' . $postId, 'nl' => 'mailing-peiling-' . $postId] as $locale => $slug) {
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $postId, 'locale' => $locale, 'slug' => $slug, 'title' => "Poll title {$locale}",
|
||||
'excerpt' => "Poll excerpt {$locale}", 'status' => 1, 'from' => now()->subDay(),
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
|
||||
return Post::with(['translations', 'poll'])->find($postId);
|
||||
}
|
||||
|
||||
public function test_poll_posts_get_the_poll_block(): void
|
||||
{
|
||||
$post = $this->makePollPost();
|
||||
|
||||
// No category needed: the poll relation decides
|
||||
$this->assertSame('poll', Mailing::determinePostType($post));
|
||||
$this->assertSame('emails.newsletter.blocks.poll', Mailing::blockTemplate('poll'));
|
||||
|
||||
// Without the config key (before config:merge) the default view is used
|
||||
config(['timebank_cc.mailing.templates.poll_block' => null, 'timebank-default.mailing.templates.poll_block' => null]);
|
||||
$this->assertSame('emails.newsletter.blocks.poll', Mailing::blockTemplate('poll'));
|
||||
}
|
||||
|
||||
public function test_block_data_links_to_the_poll_page_and_shows_the_state(): void
|
||||
{
|
||||
$open = $this->makePollPost(['closes_at' => now()->addWeek()]);
|
||||
$data = Mailing::pollBlockData($open, 'nl');
|
||||
$this->assertStringContainsString('/nl/peiling/mailing-peiling-' . $open->id, $data['url']);
|
||||
$this->assertFalse($data['poll_closed']);
|
||||
$this->assertNull($data['poll_opens']);
|
||||
$this->assertNotNull($data['poll_closes']);
|
||||
|
||||
$later = $this->makePollPost(['opens_at' => now()->addDay()]);
|
||||
$this->assertNotNull(Mailing::pollBlockData($later, 'en')['poll_opens']);
|
||||
|
||||
$closed = $this->makePollPost(['status' => Poll::STATUS_CLOSED]);
|
||||
$this->assertTrue(Mailing::pollBlockData($closed, 'en')['poll_closed']);
|
||||
|
||||
// Not a poll: nothing added
|
||||
$plain = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
$this->assertSame([], Mailing::pollBlockData($plain->load('translations'), 'en'));
|
||||
}
|
||||
|
||||
public function test_polls_for_part_of_the_members_are_recognised(): void
|
||||
{
|
||||
$everyone = $this->makePollPost()->poll;
|
||||
$this->assertFalse($everyone->hasLimitedAudience());
|
||||
|
||||
// All four profile types ticked is still everyone
|
||||
$everyone->update(['target_profile_types' => Poll::PROFILE_TYPES]);
|
||||
$this->assertFalse($everyone->fresh()->hasLimitedAudience());
|
||||
|
||||
foreach ([
|
||||
['target_profile_types' => [\App\Models\Organization::class]],
|
||||
['filter_by_location' => true],
|
||||
['last_login_from' => '2026-01-01'],
|
||||
['registered_until' => '2025-12-31'],
|
||||
['member_scope' => Poll::MEMBER_SCOPE_MANAGERS_COORDINATORS],
|
||||
] as $targeting) {
|
||||
$this->assertTrue($this->makePollPost($targeting)->poll->hasLimitedAudience(), json_encode($targeting));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_test_mail_renders_the_poll_block_in_dutch(): void
|
||||
{
|
||||
$open = $this->makePollPost(['closes_at' => now()->addWeek()]);
|
||||
$closed = $this->makePollPost(['status' => Poll::STATUS_CLOSED]);
|
||||
$mailing = Mailing::create([
|
||||
'title' => 'Poll mailing', 'type' => 'local_newsletter', 'subject' => ['nl' => 'Onderwerp'],
|
||||
'content_blocks' => [['post_id' => $open->id, 'order' => 1], ['post_id' => $closed->id, 'order' => 2]],
|
||||
]);
|
||||
|
||||
$html = (new TestNewsletterMail($mailing, 'nl'))->render();
|
||||
|
||||
$this->assertStringContainsString('Poll title nl', $html);
|
||||
$this->assertStringContainsString('/nl/peiling/mailing-peiling-' . $open->id, $html);
|
||||
$this->assertStringContainsString('Doe mee', $html);
|
||||
$this->assertStringContainsString('Open tot', $html);
|
||||
$this->assertStringContainsString('Bekijk de peiling', $html);
|
||||
$this->assertStringNotContainsString('/nl/post', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Polls\Manage;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Category;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The Polls admin page and its Edit Poll modal, rendered as a real Livewire component.
|
||||
*/
|
||||
class PollManageTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
/** An Admin profile, active in the session, whose web user holds $permission for it. */
|
||||
private function actAsAdmin(?string $permission = 'manage polls'): Admin
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
|
||||
if ($permission) {
|
||||
Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']);
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\poll-test-role", 'guard_name' => 'web']);
|
||||
$role->givePermissionTo($permission);
|
||||
$user->assignRole($role);
|
||||
}
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private function makePoll(string $title = 'Meeting frequency', array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $postId, 'locale' => 'en', 'slug' => 'poll-test-' . $postId, 'title' => $title,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_DRAFT], $attributes));
|
||||
}
|
||||
|
||||
public function test_page_requires_admin_with_posts_permission(): void
|
||||
{
|
||||
$this->actAsAdmin(null);
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_list_search_and_status_filter(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$this->makePoll('Meeting frequency');
|
||||
$this->makePoll('Garden day', ['status' => Poll::STATUS_OPEN]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->assertSee('Meeting frequency')
|
||||
->assertSee('Garden day')
|
||||
->set('search', 'Garden')
|
||||
->assertSee('Garden day')
|
||||
->assertDontSee('Meeting frequency')
|
||||
->set('search', '')
|
||||
->set('statusFilter', 'draft')
|
||||
->assertSee('Meeting frequency')
|
||||
->assertDontSee('Garden day');
|
||||
}
|
||||
|
||||
public function test_view_only_admin_can_open_but_not_save(): void
|
||||
{
|
||||
$this->actAsAdmin('view polls');
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('showEditModal', true)
|
||||
->call('savePoll')
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_build_a_poll_with_every_question_type(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
$component = Livewire::test(Manage::class)->call('openEditModal', $poll->id);
|
||||
foreach (['yes_no_conditional', 'single_choice', 'multi_choice', 'date_time_slots', 'scale', 'text', 'ranking', 'yes_no'] as $i => $type) {
|
||||
$component->set('newQuestionType', $type)->call('addQuestion')
|
||||
->set("questions.$i.label.en", "Question $type");
|
||||
}
|
||||
|
||||
// Fill in the blanks the types start with
|
||||
foreach ([1, 2, 6] as $i) {
|
||||
$component->set("questions.$i.settings.options.0.label.en", 'First')
|
||||
->set("questions.$i.settings.options.1.label.en", 'Second');
|
||||
}
|
||||
$component->set('questions.3.settings.slots.0.starts_at', '2026-11-02 10:00')
|
||||
->set('questions.2.settings.max_selections', '2') // number inputs arrive as strings
|
||||
->set('questions.6.settings.top_n', '')
|
||||
->set('form.status', Poll::STATUS_OPEN)
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showEditModal', false);
|
||||
|
||||
$questions = $poll->questions()->get();
|
||||
$this->assertSame(
|
||||
['yes_no_conditional', 'single_choice', 'multi_choice', 'date_time_slots', 'scale', 'text', 'ranking', 'yes_no'],
|
||||
$questions->pluck('type')->all()
|
||||
);
|
||||
$this->assertSame([1, 2, 3, 4, 5, 6, 7, 8], $questions->pluck('position')->all());
|
||||
$this->assertSame([], $questions[7]->settings);
|
||||
$this->assertSame('Question ranking', $questions[6]->labelFor('en'));
|
||||
$this->assertSame(2, $questions[2]->settings['max_selections']);
|
||||
$this->assertNull($questions[6]->settings['top_n']);
|
||||
$this->assertSame(Poll::STATUS_OPEN, $poll->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_option_and_date_errors_name_their_position(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('newQuestionType', 'single_choice')->call('addQuestion')
|
||||
->set('newQuestionType', 'date_time_slots')->call('addQuestion')
|
||||
->set('questions.0.label.en', 'Which day?')
|
||||
->set('questions.1.label.en', 'When?')
|
||||
->call('savePoll')
|
||||
->assertHasErrors(['questions.0.settings.options.0.label', 'questions.1.settings.slots.0.starts_at'])
|
||||
->assertSee('Question 1, option 1')
|
||||
->assertSee('Question 2, date 1')
|
||||
->assertDontSee('The questions.');
|
||||
}
|
||||
|
||||
public function test_date_fields_use_the_flatpickr_inputs(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Dates poll', ['opens_at' => '2026-11-01 09:30:00']);
|
||||
|
||||
$component = Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('form.opens_at', '2026-11-01 09:30')
|
||||
->set('newQuestionType', 'date_time_slots')
|
||||
->call('addQuestion');
|
||||
|
||||
// opens/closes, 4 targeting dates, start/end of the new slot
|
||||
$this->assertSame(8, substr_count($component->html(), 'data-selector-id='));
|
||||
$component->assertDontSeeHtml('type="datetime-local"')
|
||||
->assertDontSeeHtml('type="date"');
|
||||
}
|
||||
|
||||
public function test_post_tab_edits_texts_and_adds_a_language(): void
|
||||
{
|
||||
config(['scout.driver' => null]);
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Original title');
|
||||
PostTranslation::where('post_id', $poll->post_id)->update(['from' => '2026-10-01 00:00:00']);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('postFields.en.title', 'Original title')
|
||||
->set('postFields.en.title', 'Better title')
|
||||
->set('postFields.en.excerpt', 'Short intro')
|
||||
->set('postFields.en.content', '<p>Long <em>text</em></p>')
|
||||
->set('postFields.nl.title', 'Betere titel')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$translations = PostTranslation::where('post_id', $poll->post_id)->get()->keyBy('locale');
|
||||
$this->assertSame('Better title', $translations['en']->title);
|
||||
$this->assertSame('Short intro', $translations['en']->excerpt);
|
||||
$this->assertSame('<p>Long <em>text</em></p>', $translations['en']->content);
|
||||
$this->assertSame('poll-test-' . $poll->post_id, $translations['en']->slug); // links keep working
|
||||
$this->assertSame('Betere titel', $translations['nl']->title);
|
||||
$this->assertSame('betere-titel', $translations['nl']->slug);
|
||||
$this->assertSame('2026-10-01', substr((string) $translations['nl']->from, 0, 10)); // published like the rest
|
||||
$this->assertFalse($translations->has('de'));
|
||||
}
|
||||
|
||||
public function test_post_tab_title_rules(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Title rules');
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('postFields.en.title', '')
|
||||
->set('postFields.de.excerpt', 'Einleitung ohne Titel')
|
||||
->call('savePoll')
|
||||
->assertHasErrors(['postFields.en.title', 'postFields.de.title'])
|
||||
->assertHasNoErrors('postFields.fr.title')
|
||||
->assertSee('This tab has errors');
|
||||
|
||||
$this->assertSame('Title rules', PostTranslation::where('post_id', $poll->post_id)->sole()->title);
|
||||
}
|
||||
|
||||
public function test_post_tab_image_owner_and_captions(): void
|
||||
{
|
||||
config(['scout.driver' => null]);
|
||||
Storage::fake('media');
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Image poll');
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('image', UploadedFile::fake()->image('garden.jpg', 800, 600))
|
||||
->assertHasNoErrors('image')
|
||||
->set('mediaOwner', 'Photo: A. Gardener')
|
||||
->set('mediaCaptions.en', 'The community garden')
|
||||
->set('mediaCaptions.nl', 'De buurttuin')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$media = $poll->post->fresh()->getFirstMedia('posts');
|
||||
$this->assertNotNull($media);
|
||||
$this->assertSame('Photo: A. Gardener', $media->getCustomProperty('owner'));
|
||||
$this->assertSame('De buurttuin', $media->getCustomProperty('caption-nl'));
|
||||
|
||||
// Caption change without a new upload keeps the image
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('mediaCaptions.en', 'The community garden')
|
||||
->set('mediaCaptions.en', 'Our garden')
|
||||
->call('savePoll');
|
||||
$this->assertSame('Our garden', $poll->post->fresh()->getFirstMedia('posts')->getCustomProperty('caption-en'));
|
||||
|
||||
// Removing only happens on save
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->call('markImageForRemoval')
|
||||
->call('closeModal');
|
||||
$this->assertNotNull($poll->post->fresh()->getFirstMedia('posts'));
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->call('markImageForRemoval')
|
||||
->call('savePoll');
|
||||
$this->assertNull($poll->post->fresh()->getFirstMedia('posts'));
|
||||
}
|
||||
|
||||
public function test_post_tab_rejects_non_images(): void
|
||||
{
|
||||
Storage::fake('media');
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Upload check');
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('image', UploadedFile::fake()->create('notes.pdf', 10, 'application/pdf'))
|
||||
->assertHasErrors('image');
|
||||
}
|
||||
|
||||
public function test_content_is_read_only_once_the_poll_is_open(): void
|
||||
{
|
||||
config(['scout.driver' => null]);
|
||||
Storage::fake('media');
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Open poll', ['status' => Poll::STATUS_OPEN]);
|
||||
$question = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'required' => true,
|
||||
'label' => ['en' => 'Original question'], 'settings' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('contentLocked', true)
|
||||
->assertSee('This poll is read-only.')
|
||||
->assertSee('set the status back to Draft')
|
||||
// Tampered requests: none of these may change the content
|
||||
->set('postFields.en.title', 'Changed title')
|
||||
->set('questions.0.label.en', 'Changed question')
|
||||
->call('addQuestion')
|
||||
->set('image', UploadedFile::fake()->image('new.jpg'))
|
||||
->set('form.closes_at', '2030-01-01 12:00')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame('Open poll', PostTranslation::where('post_id', $poll->post_id)->sole()->title);
|
||||
$this->assertSame('Original question', $question->fresh()->labelFor('en'));
|
||||
$this->assertSame(1, $poll->questions()->count());
|
||||
$this->assertNull($poll->post->fresh()->getFirstMedia('posts'));
|
||||
// Settings still change
|
||||
$this->assertSame('2030-01-01 12:00', $poll->fresh()->closes_at->format('Y-m-d H:i'));
|
||||
}
|
||||
|
||||
public function test_opening_a_draft_saves_the_last_edits_then_locks(): void
|
||||
{
|
||||
config(['scout.driver' => null]);
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Draft poll');
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('contentLocked', false)
|
||||
->set('tab', 'settings')
|
||||
->assertDontSee('When you save, the poll opens')
|
||||
->set('form.status', Poll::STATUS_OPEN)
|
||||
->assertSee('When you save, the poll opens')
|
||||
->set('postFields.en.title', 'Final title')
|
||||
->set('newQuestionType', 'yes_no')
|
||||
->call('addQuestion')
|
||||
->set('questions.0.label.en', 'Final question')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame('Final title', PostTranslation::where('post_id', $poll->post_id)->sole()->title);
|
||||
$this->assertSame(1, $poll->questions()->count());
|
||||
Livewire::test(Manage::class)->call('openEditModal', $poll->id)->assertSet('contentLocked', true);
|
||||
}
|
||||
|
||||
public function test_back_to_draft_unlocks_only_without_votes(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Reopened poll', ['status' => Poll::STATUS_OPEN]);
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'required' => true,
|
||||
'label' => ['en' => 'Question'], 'settings' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('form.status', Poll::STATUS_DRAFT)
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
Livewire::test(Manage::class)->call('openEditModal', $poll->id)->assertSet('contentLocked', false);
|
||||
|
||||
// With a vote it stays open and locked
|
||||
$poll->refresh()->update(['status' => Poll::STATUS_OPEN]);
|
||||
$voter = User::factory()->create();
|
||||
PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
|
||||
]);
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSee('People have already voted')
|
||||
->set('form.status', Poll::STATUS_DRAFT)
|
||||
->call('savePoll')
|
||||
->assertHasErrors('form.status');
|
||||
$this->assertSame(Poll::STATUS_OPEN, $poll->fresh()->status);
|
||||
}
|
||||
|
||||
/** An open poll with one question and one vote. */
|
||||
private function votedPoll(array $attributes = []): Poll
|
||||
{
|
||||
$poll = $this->makePoll('Voted poll', array_merge(['status' => Poll::STATUS_OPEN], $attributes));
|
||||
\App\Models\PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => []]);
|
||||
$voter = User::factory()->create();
|
||||
PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
public function test_anonymity_cannot_be_switched_off_after_voting(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->votedPoll(['anonymous' => true]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('form.anonymous', false)
|
||||
->call('savePoll')
|
||||
->assertHasErrors('form.anonymous');
|
||||
$this->assertTrue($poll->fresh()->anonymous);
|
||||
|
||||
// Switching it on stays possible
|
||||
$open = $this->votedPoll(['anonymous' => false]);
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $open->id)
|
||||
->set('form.anonymous', true)
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
$this->assertTrue($open->fresh()->anonymous);
|
||||
}
|
||||
|
||||
public function test_vote_scope_is_fixed_after_voting(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->votedPoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('form.vote_scope', Poll::VOTE_SCOPE_PER_USER)
|
||||
->call('savePoll')
|
||||
->assertHasErrors('form.vote_scope');
|
||||
$this->assertSame(Poll::VOTE_SCOPE_PER_PROFILE, $poll->fresh()->vote_scope);
|
||||
|
||||
// Without votes it can still change
|
||||
$fresh = $this->makePoll('No votes yet');
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $fresh->id)
|
||||
->set('form.vote_scope', Poll::VOTE_SCOPE_PER_USER)
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
$this->assertSame(Poll::VOTE_SCOPE_PER_USER, $fresh->fresh()->vote_scope);
|
||||
}
|
||||
|
||||
public function test_sealed_poll_keeps_its_status_and_dates(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->votedPoll(['closes_at' => '2026-10-01 12:00:00']);
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
app(\App\Polls\Ledger::class)->seal($poll);
|
||||
$this->assertNotNull($poll->fresh()->sealed_at);
|
||||
|
||||
Livewire::test(Manage::class)->call('openEditModal', $poll->id)
|
||||
->set('form.status', Poll::STATUS_OPEN)->call('savePoll')->assertHasErrors('form.status');
|
||||
Livewire::test(Manage::class)->call('openEditModal', $poll->id)
|
||||
->set('form.closes_at', '2027-01-01 12:00')->call('savePoll')->assertHasErrors('form.status');
|
||||
$this->assertSame(Poll::STATUS_CLOSED, $poll->fresh()->status);
|
||||
$this->assertSame('2026-10-01 12:00', $poll->fresh()->closes_at->format('Y-m-d H:i'));
|
||||
|
||||
// Other settings can still be saved
|
||||
Livewire::test(Manage::class)->call('openEditModal', $poll->id)
|
||||
->set('form.results_visibility', Poll::RESULTS_AFTER_CLOSE)->call('savePoll')->assertHasNoErrors();
|
||||
$this->assertSame(Poll::RESULTS_AFTER_CLOSE, $poll->fresh()->results_visibility);
|
||||
}
|
||||
|
||||
public function test_unknown_question_settings_are_not_stored(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('newQuestionType', 'single_choice')->call('addQuestion')
|
||||
->set('questions.0.label.en', 'Which day?')
|
||||
->set('questions.0.settings.options.0.label.en', 'Saturday')
|
||||
->set('questions.0.settings.options.1.label.en', 'Sunday')
|
||||
// Tampered client state: extra keys at both levels
|
||||
->set('questions.0.settings.injected', ['anything' => true])
|
||||
->set('questions.0.settings.options.0.extra', 'x')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$settings = $poll->questions()->first()->settings;
|
||||
$this->assertSame(['options', 'allow_other'], array_keys($settings));
|
||||
$this->assertSame(['key', 'label'], array_keys($settings['options'][0]));
|
||||
$this->assertSame('Saturday', $settings['options'][0]['label']['en']);
|
||||
}
|
||||
|
||||
public function test_delete_a_poll_without_votes(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Unwanted poll');
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('confirmDelete', $poll->id)
|
||||
->assertSet('showDeleteModal', true)
|
||||
->assertSee('Unwanted poll')
|
||||
->call('deletePoll')
|
||||
->assertSet('showDeleteModal', false);
|
||||
|
||||
$this->assertSoftDeleted('polls', ['id' => $poll->id]);
|
||||
$this->assertSoftDeleted('posts', ['id' => $poll->post_id]);
|
||||
}
|
||||
|
||||
public function test_only_drafts_without_ledger_entries_are_deleted(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$voted = $this->votedPoll();
|
||||
|
||||
Livewire::test(Manage::class)->call('confirmDelete', $voted->id)->call('deletePoll');
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $voted->id]);
|
||||
|
||||
// Opened once (closed now), nobody voted: archive instead of delete
|
||||
$closed = $this->makePoll('Closed without votes', ['status' => Poll::STATUS_CLOSED]);
|
||||
Livewire::test(Manage::class)->call('confirmDelete', $closed->id)->call('deletePoll');
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $closed->id]);
|
||||
|
||||
// A withdrawn vote leaves no response but its ballot stays in the ledger
|
||||
$withdrawn = $this->makePoll('Withdrawn vote');
|
||||
DB::transaction(fn () => app(\App\Polls\Ledger::class)->append($withdrawn, \App\Models\PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'x')));
|
||||
Livewire::test(Manage::class)->call('confirmDelete', $withdrawn->id)->call('deletePoll');
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $withdrawn->id]);
|
||||
}
|
||||
|
||||
public function test_deleting_needs_the_delete_permission(): void
|
||||
{
|
||||
$this->actAsAdmin('view polls'); // may open the page, not delete
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)->call('confirmDelete', $poll->id)->assertStatus(403);
|
||||
Livewire::test(Manage::class)->call('deletePoll')->assertStatus(403);
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $poll->id]);
|
||||
}
|
||||
|
||||
public function test_archived_polls_only_show_under_the_archived_filter(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$kept = $this->makePoll('Kept poll', ['status' => Poll::STATUS_CLOSED]);
|
||||
$old = $this->votedPoll(['status' => Poll::STATUS_CLOSED]);
|
||||
$old->post->translations()->update(['title' => 'Old voted poll']);
|
||||
|
||||
$component = Livewire::test(Manage::class)
|
||||
->call('archivePoll', $old->id)
|
||||
->assertSee('Kept poll')
|
||||
->assertDontSee('Old voted poll');
|
||||
$this->assertNotNull($old->fresh()->archived_at);
|
||||
|
||||
// Not under the status filters either
|
||||
$component->set('statusFilter', Poll::STATUS_CLOSED)->assertSee('Kept poll')->assertDontSee('Old voted poll');
|
||||
|
||||
// Only under "Archived"
|
||||
$component->set('statusFilter', 'archived')->assertSee('Old voted poll')->assertDontSee('Kept poll');
|
||||
|
||||
// And back
|
||||
$component->call('restoreFromArchive', $old->id)->set('statusFilter', '')->assertSee('Old voted poll');
|
||||
$this->assertNull($old->fresh()->archived_at);
|
||||
}
|
||||
|
||||
public function test_an_open_poll_cannot_be_archived(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$open = $this->votedPoll();
|
||||
|
||||
Livewire::test(Manage::class)->call('archivePoll', $open->id);
|
||||
$this->assertNull($open->fresh()->archived_at);
|
||||
}
|
||||
|
||||
public function test_archiving_needs_the_update_permission(): void
|
||||
{
|
||||
$this->actAsAdmin('view polls');
|
||||
$poll = $this->makePoll('Closed', ['status' => Poll::STATUS_CLOSED]);
|
||||
|
||||
Livewire::test(Manage::class)->call('archivePoll', $poll->id)->assertStatus(403);
|
||||
$this->assertNull($poll->fresh()->archived_at);
|
||||
}
|
||||
|
||||
public function test_posts_page_cannot_edit_the_post_of_an_open_poll(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Guarded poll', ['status' => Poll::STATUS_OPEN]);
|
||||
$translationId = PostTranslation::where('post_id', $poll->post_id)->value('id');
|
||||
// The posts editor shows the category name
|
||||
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
\App\Models\CategoryTranslation::firstOrCreate(
|
||||
['category_id' => $category->id, 'locale' => 'en'],
|
||||
['name' => 'Poll', 'slug' => 'poll-manage-test-' . $category->id]
|
||||
);
|
||||
DB::table('posts')->where('id', $poll->post_id)->update(['category_id' => $category->id]);
|
||||
if (!DB::table('languages')->where('lang_code', 'en')->exists()) {
|
||||
DB::table('languages')->insert(['name' => 'English', 'lang_code' => 'en', 'flag' => '']);
|
||||
}
|
||||
|
||||
Livewire::test(\App\Http\Livewire\Posts\Manage::class)
|
||||
->call('edit', $translationId)
|
||||
->assertSet('postId', null);
|
||||
|
||||
$poll->refresh()->update(['status' => Poll::STATUS_DRAFT]);
|
||||
Livewire::test(\App\Http\Livewire\Posts\Manage::class)
|
||||
->call('edit', $translationId)
|
||||
->assertSet('postId', $poll->post_id);
|
||||
}
|
||||
|
||||
public function test_invalid_questions_are_not_saved(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('newQuestionType', 'single_choice')
|
||||
->call('addQuestion')
|
||||
->call('savePoll')
|
||||
->assertHasErrors(['questions.0.label', 'questions.0.settings.options.0.label'])
|
||||
->assertSet('showEditModal', true)
|
||||
->assertSee('This tab has errors');
|
||||
|
||||
$this->assertSame(0, $poll->questions()->count());
|
||||
}
|
||||
|
||||
public function test_cannot_open_a_poll_without_questions(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('form.status', Poll::STATUS_OPEN)
|
||||
->call('savePoll')
|
||||
->assertHasErrors('form.status')
|
||||
->assertSet('tab', 'settings');
|
||||
|
||||
$this->assertSame(Poll::STATUS_DRAFT, $poll->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_changing_type_resets_settings_and_list_items_can_be_edited(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
|
||||
$component = Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('newQuestionType', 'scale')
|
||||
->call('addQuestion')
|
||||
->assertSet('questions.0.settings.max', 5)
|
||||
->set('questions.0.type', 'ranking')
|
||||
->assertSet('questions.0.settings.top_n', null)
|
||||
->call('addListItem', 0, 'options')
|
||||
->set('questions.0.settings.options.0.label.en', 'A')
|
||||
->set('questions.0.settings.options.2.label.en', 'C')
|
||||
->call('moveListItem', 0, 'options', 2, -1)
|
||||
->call('removeListItem', 0, 'options', 2)
|
||||
->call('addListItem', 0, 'not-a-list');
|
||||
|
||||
$options = $component->get('questions.0.settings.options');
|
||||
$this->assertCount(2, $options);
|
||||
$this->assertSame(['A', 'C'], array_map(fn ($option) => $option['label']['en'] ?? null, $options));
|
||||
}
|
||||
|
||||
public function test_questions_are_locked_once_someone_voted(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Locked poll', ['status' => Poll::STATUS_OPEN]);
|
||||
$question = PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'text', 'required' => true,
|
||||
'label' => ['en' => 'Original'], 'settings' => ['multiline' => true, 'max_length' => 100],
|
||||
]);
|
||||
$voter = User::factory()->create();
|
||||
PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->assertSet('contentLocked', true)
|
||||
->assertSee('This poll is read-only.')
|
||||
->call('addQuestion')
|
||||
->call('removeQuestion', 0)
|
||||
->assertCount('questions', 1)
|
||||
->set('questions.0.label.en', 'Tampered')
|
||||
->set('form.closes_at', '2030-01-01 12:00')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame('Original', $question->fresh()->labelFor('en'));
|
||||
$this->assertSame('2030-01-01 12:00', $poll->fresh()->closes_at->format('Y-m-d H:i'));
|
||||
}
|
||||
|
||||
public function test_tampered_question_id_cannot_touch_another_poll(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$other = $this->makePoll('Other poll');
|
||||
$foreign = PollQuestion::create([
|
||||
'poll_id' => $other->id, 'type' => 'text', 'label' => ['en' => 'Foreign'],
|
||||
'settings' => ['multiline' => true, 'max_length' => 100],
|
||||
]);
|
||||
$poll = $this->makePoll();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('newQuestionType', 'text')
|
||||
->call('addQuestion')
|
||||
->set('questions.0.id', $foreign->id)
|
||||
->set('questions.0.label.en', 'Hijacked')
|
||||
->call('savePoll');
|
||||
|
||||
$this->assertSame('Foreign', $foreign->fresh()->labelFor('en'));
|
||||
$this->assertSame($other->id, $foreign->fresh()->poll_id);
|
||||
}
|
||||
|
||||
public function test_targeting_is_saved_and_counted(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll();
|
||||
User::factory()->create(['last_login_at' => '2026-09-15 10:00:00']);
|
||||
Organization::factory()->create(['last_login_at' => '2026-09-15 10:00:00']);
|
||||
|
||||
$component = Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('tab', 'targeting')
|
||||
->set('targeting.target_profile_types', [User::class, Organization::class])
|
||||
->set('targeting.last_login_from', '2026-09-01')
|
||||
->set('targeting.last_login_until', '2026-09-30')
|
||||
->call('countMatchingProfiles');
|
||||
|
||||
$counts = $component->get('matchingCounts');
|
||||
$this->assertSame(['User', 'Organization'], array_keys($counts));
|
||||
$this->assertSame(
|
||||
User::whereDate('last_login_at', '>=', '2026-09-01')->whereDate('last_login_at', '<=', '2026-09-30')->count(),
|
||||
$counts['User']
|
||||
);
|
||||
|
||||
$component->call('savePoll')->assertHasNoErrors();
|
||||
$poll->refresh();
|
||||
$this->assertSame([User::class, Organization::class], $poll->target_profile_types);
|
||||
$this->assertSame('2026-09-30', $poll->last_login_until->toDateString());
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('targeting.last_login_until', '2026-08-01')
|
||||
->call('savePoll')
|
||||
->assertHasErrors('targeting.last_login_until');
|
||||
}
|
||||
|
||||
public function test_results_tab_hides_voters_of_anonymous_polls(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Results poll', ['status' => Poll::STATUS_OPEN]);
|
||||
$voter = User::factory()->create(['name' => 'Visible Voter Name']);
|
||||
PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('tab', 'results')
|
||||
->assertSee('Visible Voter Name')
|
||||
->assertSee('Ballot ledger');
|
||||
|
||||
$poll->update(['anonymous' => true]);
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('tab', 'results')
|
||||
->assertDontSee('Visible Voter Name')
|
||||
->assertSee('This poll is anonymous');
|
||||
}
|
||||
|
||||
public function test_edit_query_parameter_opens_the_modal(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Deep link poll');
|
||||
|
||||
Livewire::withQueryParams(['edit' => $poll->id])
|
||||
->test(Manage::class)
|
||||
->assertSet('showEditModal', true)
|
||||
->assertSet('editingPollId', $poll->id);
|
||||
}
|
||||
|
||||
public function test_opened_from_the_posts_table_saving_returns_there_with_the_message(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Back to posts');
|
||||
|
||||
Livewire::withQueryParams(['edit' => $poll->id, 'from' => 'posts'])
|
||||
->test(Manage::class)
|
||||
->assertSet('returnToPosts', true)
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('posts.manage'));
|
||||
|
||||
$this->assertSame('success', session('posts.manage.notification')['icon']);
|
||||
|
||||
// The posts table shows it
|
||||
Livewire::test(\App\Http\Livewire\Posts\Manage::class)
|
||||
->assertDispatched('wireui:notification');
|
||||
}
|
||||
|
||||
public function test_opened_from_the_posts_table_closing_returns_there(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Close to posts');
|
||||
|
||||
Livewire::withQueryParams(['edit' => $poll->id, 'from' => 'posts'])
|
||||
->test(Manage::class)
|
||||
->call('closeModal')
|
||||
->assertRedirect(route('posts.manage'));
|
||||
|
||||
// Close button and Escape set the modal property directly
|
||||
Livewire::withQueryParams(['edit' => $poll->id, 'from' => 'posts'])
|
||||
->test(Manage::class)
|
||||
->set('showEditModal', false)
|
||||
->assertRedirect(route('posts.manage'));
|
||||
}
|
||||
|
||||
public function test_opened_in_the_poll_manager_saving_stays_there(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Stay here');
|
||||
|
||||
Livewire::withQueryParams(['edit' => $poll->id])
|
||||
->test(Manage::class)
|
||||
->assertSet('returnToPosts', false)
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors()
|
||||
->assertNoRedirect()
|
||||
->assertSet('showEditModal', false)
|
||||
->assertDispatched('wireui:notification');
|
||||
}
|
||||
|
||||
public function test_new_poll_creates_post_and_opens_the_editor(): void
|
||||
{
|
||||
config(['scout.driver' => null]); // no Elasticsearch in tests
|
||||
$admin = $this->actAsAdmin();
|
||||
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
|
||||
$component = Livewire::test(Manage::class)
|
||||
->call('openCreateModal')
|
||||
->assertSet('showCreateModal', true)
|
||||
->set('newPoll.locale', 'nl')
|
||||
->set('newPoll.title', 'Hoe vaak willen we samenkomen?')
|
||||
->set('newPoll.excerpt', 'Geef je mening.')
|
||||
->call('createPoll')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showCreateModal', false)
|
||||
->assertSet('showEditModal', true)
|
||||
->assertSet('tab', 'questions');
|
||||
|
||||
$poll = Poll::findOrFail($component->get('editingPollId'));
|
||||
$this->assertSame(Poll::STATUS_DRAFT, $poll->status);
|
||||
$this->assertTrue($poll->anonymous); // new polls start anonymous
|
||||
$this->assertTrue($poll->pollable->is($admin));
|
||||
$this->assertSame($category->id, $poll->post->category_id);
|
||||
|
||||
$translation = PostTranslation::where('post_id', $poll->post_id)->sole();
|
||||
$this->assertSame('nl', $translation->locale);
|
||||
$this->assertSame('Hoe vaak willen we samenkomen?', $translation->title);
|
||||
$this->assertSame('Geef je mening.', $translation->excerpt);
|
||||
$this->assertSame('hoe-vaak-willen-we-samenkomen', $translation->slug);
|
||||
}
|
||||
|
||||
public function test_new_poll_validates_and_needs_create_permission(): void
|
||||
{
|
||||
config(['scout.driver' => null]);
|
||||
$this->actAsAdmin();
|
||||
Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
$before = Poll::count();
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openCreateModal')
|
||||
->set('newPoll.title', 'ab')
|
||||
->call('createPoll')
|
||||
->assertHasErrors('newPoll.title')
|
||||
->set('newPoll.title', 'Valid title')
|
||||
->set('newPoll.locale', 'xx')
|
||||
->call('createPoll')
|
||||
->assertHasErrors('newPoll.locale');
|
||||
$this->assertSame($before, Poll::count());
|
||||
|
||||
$this->actAsAdmin('view polls');
|
||||
Livewire::test(Manage::class)->call('openCreateModal')->assertStatus(403);
|
||||
Livewire::test(Manage::class)
|
||||
->set('newPoll', ['locale' => 'en', 'title' => 'Sneaky poll', 'excerpt' => ''])
|
||||
->call('createPoll')
|
||||
->assertStatus(403);
|
||||
$this->assertSame($before, Poll::count());
|
||||
}
|
||||
|
||||
public function test_saving_a_post_in_a_poll_category_creates_a_draft_poll(): void
|
||||
{
|
||||
$admin = $this->actAsAdmin();
|
||||
$pollCategory = Category::create(['type' => Poll::class]);
|
||||
$newsCategory = Category::create(['type' => 'App\Models\News']);
|
||||
|
||||
$method = new \ReflectionMethod(\App\Http\Livewire\Posts\Manage::class, 'ensureDraftPoll');
|
||||
$method->setAccessible(true);
|
||||
$posts = new \App\Http\Livewire\Posts\Manage();
|
||||
|
||||
$pollPost = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => $admin->id, 'postable_type' => Admin::class, 'category_id' => $pollCategory->id]));
|
||||
$newsPost = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => $admin->id, 'postable_type' => Admin::class, 'category_id' => $newsCategory->id]));
|
||||
|
||||
$method->invoke($posts, $pollPost);
|
||||
$method->invoke($posts, $pollPost); // saving again keeps the one poll
|
||||
$method->invoke($posts, $newsPost);
|
||||
|
||||
$this->assertSame(1, Poll::where('post_id', $pollPost->id)->count());
|
||||
$poll = Poll::where('post_id', $pollPost->id)->sole();
|
||||
$this->assertSame(Poll::STATUS_DRAFT, $poll->status);
|
||||
$this->assertTrue($poll->anonymous);
|
||||
$this->assertTrue($poll->pollable->is($admin));
|
||||
$this->assertSame(0, Poll::where('post_id', $newsPost->id)->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Relations, cascades and the one-vote unique index of the poll tables.
|
||||
*/
|
||||
class PollModelTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
// Inserted directly: Post is Searchable and would sync to Elasticsearch
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => 'App\Models\Admin',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, $profile, User $user): PollResponse
|
||||
{
|
||||
return PollResponse::create([
|
||||
'poll_id' => $poll->id,
|
||||
'voter_id' => $profile->id,
|
||||
'voter_type' => get_class($profile),
|
||||
'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id),
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_relations_and_casts(): void
|
||||
{
|
||||
$poll = $this->makePoll(['target_profile_types' => [User::class]]);
|
||||
$question = PollQuestion::create([
|
||||
'poll_id' => $poll->id,
|
||||
'type' => 'single_choice',
|
||||
'label' => ['en' => 'Which day?', 'nl' => 'Welke dag?'],
|
||||
'settings' => ['options' => ['mon' => 'Monday']],
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
$response = $this->vote($poll, $user, $user);
|
||||
PollAnswer::create([
|
||||
'poll_response_id' => $response->id,
|
||||
'poll_question_id' => $question->id,
|
||||
'value' => ['mon'],
|
||||
]);
|
||||
|
||||
$post = Post::find($poll->post_id);
|
||||
$this->assertTrue($post->poll->is($poll));
|
||||
$this->assertSame([User::class], $poll->fresh()->target_profile_types);
|
||||
$this->assertSame('Welke dag?', $question->labelFor('nl'));
|
||||
$this->assertSame('Which day?', $question->labelFor('de')); // fallback locale
|
||||
$this->assertTrue($response->voter->is($user));
|
||||
$this->assertSame(['mon'], $poll->responses->first()->answers->first()->value);
|
||||
$this->assertTrue($poll->hasResponses());
|
||||
}
|
||||
|
||||
public function test_is_open_respects_status_and_window(): void
|
||||
{
|
||||
$this->assertTrue($this->makePoll()->isOpen());
|
||||
$this->assertFalse($this->makePoll(['status' => Poll::STATUS_DRAFT])->isOpen());
|
||||
$this->assertFalse($this->makePoll(['opens_at' => now()->addDay()])->isOpen());
|
||||
$this->assertFalse($this->makePoll(['closes_at' => now()->subMinute()])->isOpen());
|
||||
}
|
||||
|
||||
public function test_per_profile_poll_allows_one_vote_per_organization(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
$this->vote($poll, $organization, $manager1);
|
||||
|
||||
$this->expectException(QueryException::class);
|
||||
$this->vote($poll, $organization, $manager2);
|
||||
}
|
||||
|
||||
public function test_per_user_poll_allows_one_vote_per_user_behind_organization(): void
|
||||
{
|
||||
$poll = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
$this->vote($poll, $organization, $manager1);
|
||||
$this->vote($poll, $organization, $manager2);
|
||||
$this->assertSame(2, $poll->responses()->count());
|
||||
|
||||
$this->expectException(QueryException::class);
|
||||
$this->vote($poll, $organization, $manager1);
|
||||
}
|
||||
|
||||
public function test_deleting_user_or_post_removes_votes(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user);
|
||||
|
||||
DB::table('users')->where('id', $user->id)->delete();
|
||||
$this->assertSame(0, $poll->responses()->count());
|
||||
|
||||
$this->vote($poll, $other = User::factory()->create(), $other);
|
||||
DB::table('posts')->where('id', $poll->post_id)->delete();
|
||||
$this->assertSame(0, PollResponse::where('user_id', $other->id)->count());
|
||||
$this->assertNull(Poll::withTrashed()->find($poll->id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\MainPage\PollCardFull;
|
||||
use App\Http\Livewire\Polls\Manage;
|
||||
use App\Http\Livewire\Polls\PollForm;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Category;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Where polls appear: publishing on open, the late-publication warning,
|
||||
* the main-page card, the post pages and the closed state.
|
||||
*/
|
||||
class PollPagesTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array', 'scout.driver' => null]);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
app()->setLocale('en');
|
||||
}
|
||||
|
||||
/** A poll with one yes/no question; $from is the post's publication date (null = unpublished). */
|
||||
private function makePoll(string $title, array $attributes = [], ?string $from = '2026-01-01 00:00:00'): Poll
|
||||
{
|
||||
$category = Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
\App\Models\CategoryTranslation::firstOrCreate(
|
||||
['category_id' => $category->id, 'locale' => 'en'],
|
||||
['name' => 'Poll', 'slug' => 'poll-pages-test-' . $category->id]
|
||||
);
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'category_id' => $category->id,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $postId, 'locale' => 'en', 'slug' => 'poll-pages-' . $postId, 'title' => $title,
|
||||
'excerpt' => "Intro of $title", 'from' => $from, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'required' => true,
|
||||
'label' => ['en' => "Question of $title"], 'settings' => [],
|
||||
]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
private function actAs(Model $profile, User $user): void
|
||||
{
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]);
|
||||
}
|
||||
|
||||
private function actAsAdmin(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\poll-pages-role", 'guard_name' => 'web']);
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage polls', 'guard_name' => 'web']));
|
||||
$user->assignRole($role);
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, User $user): void
|
||||
{
|
||||
PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $user->id, 'voter_type' => User::class, 'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($user, $user->id), 'submitted_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Publishing
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function test_opening_a_poll_publishes_its_unpublished_post(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Unpublished poll', ['status' => Poll::STATUS_DRAFT], null);
|
||||
$translation = PostTranslation::where('post_id', $poll->post_id)->sole();
|
||||
|
||||
// Saving as draft leaves the post unpublished
|
||||
Livewire::test(Manage::class)->call('openEditModal', $poll->id)->call('savePoll');
|
||||
$this->assertNull($translation->fresh()->from);
|
||||
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('form.status', Poll::STATUS_OPEN)
|
||||
->set('form.opens_at', '2026-11-01 09:00')
|
||||
->call('savePoll')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame('2026-11-01 09:00', \Illuminate\Support\Carbon::parse($translation->fresh()->from)->format('Y-m-d H:i'));
|
||||
}
|
||||
|
||||
public function test_an_existing_publication_date_is_kept_and_a_later_one_warned_about(): void
|
||||
{
|
||||
$this->actAsAdmin();
|
||||
$poll = $this->makePoll('Late post', ['status' => Poll::STATUS_DRAFT], '2026-12-24 08:00:00');
|
||||
|
||||
$component = Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('tab', 'settings')
|
||||
->assertDontSee('is published later than the poll opens')
|
||||
->set('form.status', Poll::STATUS_OPEN)
|
||||
->set('form.opens_at', '2026-11-01 09:00')
|
||||
->assertSee('is published later than the poll opens')
|
||||
->assertSee('EN:');
|
||||
|
||||
$this->assertArrayHasKey('en', $component->instance()->latePublications());
|
||||
|
||||
$component->call('savePoll')->assertHasNoErrors();
|
||||
$this->assertSame('2026-12-24 08:00', \Illuminate\Support\Carbon::parse(PostTranslation::where('post_id', $poll->post_id)->sole()->from)->format('Y-m-d H:i'));
|
||||
|
||||
// Opening on or after the publication date: no warning
|
||||
Livewire::test(Manage::class)
|
||||
->call('openEditModal', $poll->id)
|
||||
->set('form.opens_at', '2026-12-24 08:00')
|
||||
->set('tab', 'settings')
|
||||
->assertDontSee('is published later than the poll opens');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Main-page card
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function test_card_shows_the_soonest_closing_unanswered_poll(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
// Other open polls in the test database would compete for the card; close them for this test
|
||||
Poll::query()->update(['status' => Poll::STATUS_CLOSED]);
|
||||
|
||||
$later = $this->makePoll('Closes later', ['closes_at' => now()->addDays(10)]);
|
||||
$sooner = $this->makePoll('Closes sooner', ['closes_at' => now()->addDays(2)]);
|
||||
$this->makePoll('Not for users', ['target_profile_types' => [Organization::class]]);
|
||||
$this->makePoll('Unpublished', [], null);
|
||||
|
||||
Livewire::test(PollCardFull::class)
|
||||
->assertSet('pollId', $sooner->id)
|
||||
->assertSee('Closes sooner')
|
||||
->assertSee('Intro of Closes sooner')
|
||||
->assertSee('Question of Closes sooner');
|
||||
|
||||
// Answered: the next one takes its place
|
||||
$this->vote($sooner, $user);
|
||||
Livewire::test(PollCardFull::class)->assertSet('pollId', $later->id);
|
||||
|
||||
$this->vote($later, $user);
|
||||
Livewire::test(PollCardFull::class)->assertSet('pollId', null)->assertDontSee('Question of');
|
||||
}
|
||||
|
||||
public function test_card_links_to_the_poll_page_for_several_questions(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
Poll::query()->update(['status' => Poll::STATUS_CLOSED]);
|
||||
$poll = $this->makePoll('Long poll');
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'position' => 2, 'type' => 'yes_no', 'required' => true,
|
||||
'label' => ['en' => 'Second question of Long poll'], 'settings' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(PollCardFull::class)
|
||||
->assertSet('pollId', $poll->id)
|
||||
->assertSee('Long poll')
|
||||
->assertSee('Intro of Long poll')
|
||||
->assertSee('Take part')
|
||||
->assertSee(route('poll.show', ['slug' => $this->slugOf($poll)]), false)
|
||||
->assertDontSee('Question of Long poll')
|
||||
->assertDontSee('Read more');
|
||||
}
|
||||
|
||||
public function test_card_shows_a_single_question_with_read_more_when_there_is_content(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
Poll::query()->update(['status' => Poll::STATUS_CLOSED]);
|
||||
$poll = $this->makePoll('Short poll');
|
||||
|
||||
// No content: just the question
|
||||
Livewire::test(PollCardFull::class)
|
||||
->assertSee('Question of Short poll')
|
||||
->assertDontSee('Read more')
|
||||
->assertDontSee('Take part');
|
||||
|
||||
// With content: "Read more..." to the poll page, above the question
|
||||
PostTranslation::where('post_id', $poll->post_id)->update(['content' => '<p>The background of this poll.</p>']);
|
||||
Livewire::test(PollCardFull::class)
|
||||
->assertSeeInOrder(['Read more...', 'Question of Short poll'])
|
||||
->assertSee(route('poll.show', ['slug' => $this->slugOf($poll)]), false)
|
||||
->assertDontSee('The background of this poll.');
|
||||
|
||||
// Empty markup from the editor counts as no content
|
||||
PostTranslation::where('post_id', $poll->post_id)->update(['content' => '<p><br></p>']);
|
||||
Livewire::test(PollCardFull::class)->assertDontSee('Read more');
|
||||
}
|
||||
|
||||
public function test_card_is_empty_for_guests(): void
|
||||
{
|
||||
Livewire::test(PollCardFull::class)->assertSet('pollId', null);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Closed polls on the post page
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function test_closed_poll_shows_results_on_the_post_page_only(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
$poll = $this->makePoll('Finished poll', [
|
||||
'status' => Poll::STATUS_CLOSED,
|
||||
'results_visibility' => Poll::RESULTS_AFTER_CLOSE,
|
||||
]);
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSet('state', PollForm::STATE_CLOSED)
|
||||
->assertSee('This poll is closed.')
|
||||
->assertSee('Results')
|
||||
->assertDontSee('Submit vote');
|
||||
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id, 'context' => 'card'])
|
||||
->assertSet('state', PollForm::STATE_HIDDEN);
|
||||
|
||||
// Results hidden when the poll never shows them
|
||||
$poll->update(['results_visibility' => Poll::RESULTS_NEVER]);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSee('This poll is closed.')
|
||||
->assertDontSee('Question of Finished poll');
|
||||
|
||||
// Profiles the poll was not for see nothing
|
||||
$poll->update(['target_profile_types' => [Organization::class]]);
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])->assertSet('state', PollForm::STATE_HIDDEN);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post pages
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render the poll page through PollController::show. Called directly: localized
|
||||
* routes are registered without a locale prefix in tests, so an HTTP request would 404.
|
||||
*/
|
||||
private function pollPage(string $slug): string
|
||||
{
|
||||
$view = app(\App\Http\Controllers\PollController::class)->show($slug);
|
||||
|
||||
return $view->render();
|
||||
}
|
||||
|
||||
private function slugOf(Poll $poll): string
|
||||
{
|
||||
return PostTranslation::where('post_id', $poll->post_id)->value('slug');
|
||||
}
|
||||
|
||||
public function test_poll_page_is_only_for_logged_in_profiles(): void
|
||||
{
|
||||
// Same protection as the other member pages: login, verified email, completed
|
||||
// registration and accepted principles
|
||||
$middleware = app('router')->getRoutes()->getByName('poll.show')->gatherMiddleware();
|
||||
foreach (['auth:web', 'verified', 'registration-complete', 'principles-accepted'] as $required) {
|
||||
$this->assertContains($required, $middleware);
|
||||
}
|
||||
|
||||
// Safety net in the controller: a guest is sent to the login and the poll is remembered
|
||||
$poll = $this->makePoll('Guest poll');
|
||||
$slug = $this->slugOf($poll);
|
||||
$response = $this->pollResponse($slug);
|
||||
$this->assertTrue($response->isRedirect());
|
||||
$this->assertStringContainsString('/login', $response->getTargetUrl());
|
||||
$this->assertStringEndsWith('/' . $slug, session('url.intended'));
|
||||
}
|
||||
|
||||
public function test_explicit_indexing_never_indexes_a_poll(): void
|
||||
{
|
||||
// A spy engine records what Scout sends; synchronous so nothing is queued
|
||||
$engine = new class extends \Laravel\Scout\Engines\NullEngine {
|
||||
public array $updated = [];
|
||||
public array $deleted = [];
|
||||
public function update($models) { $this->updated = array_merge($this->updated, $models->modelKeys()); }
|
||||
public function delete($models) { $this->deleted = array_merge($this->deleted, $models->modelKeys()); }
|
||||
};
|
||||
app(\Laravel\Scout\EngineManager::class)->extend('spy', fn () => $engine);
|
||||
config(['scout.driver' => 'spy', 'scout.queue' => false]);
|
||||
|
||||
$poll = $this->makePoll('Never indexed');
|
||||
$pollPost = \App\Models\Post::find($poll->post_id);
|
||||
$plain = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
|
||||
// $post->searchable(), as Polls/Manage, Posts/Manage and the observers call it
|
||||
$pollPost->searchable();
|
||||
$this->assertNotContains($pollPost->id, $engine->updated);
|
||||
$this->assertContains($pollPost->id, $engine->deleted);
|
||||
|
||||
// A collection with a poll and a normal post: only the normal post is indexed
|
||||
$engine->updated = $engine->deleted = [];
|
||||
\App\Models\Post::whereKey([$pollPost->id, $plain->id])->get()->searchable();
|
||||
$this->assertSame([$plain->id], $engine->updated);
|
||||
$this->assertSame([$pollPost->id], $engine->deleted);
|
||||
}
|
||||
|
||||
public function test_poll_posts_stay_out_of_search_and_guest_pdfs(): void
|
||||
{
|
||||
$poll = $this->makePoll('Hidden poll');
|
||||
$post = \App\Models\Post::find($poll->post_id);
|
||||
$this->assertFalse($post->shouldBeSearchable());
|
||||
|
||||
$plain = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
$this->assertTrue($plain->shouldBeSearchable());
|
||||
|
||||
// A poll attached to a post of another category counts too
|
||||
$other = \App\Models\Post::withoutSyncingToSearch(fn () => \App\Models\Post::create(['postable_id' => 1, 'postable_type' => Admin::class]));
|
||||
Poll::create(['post_id' => $other->id, 'status' => Poll::STATUS_OPEN]);
|
||||
$this->assertFalse($other->fresh()->shouldBeSearchable());
|
||||
|
||||
try {
|
||||
app(\App\Http\Controllers\PostController::class)->postPdf($poll->post_id);
|
||||
$this->fail('A guest got the PDF of a poll post');
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
$this->assertSame(404, $e->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/** Render or redirect the poll page as the current session, with optional query. */
|
||||
private function pollResponse(string $slug, string $query = '')
|
||||
{
|
||||
$request = \Illuminate\Http\Request::create(url('/en/poll/' . $slug . $query));
|
||||
$request->setLaravelSession($this->app['session.store']);
|
||||
$this->app->instance('request', $request);
|
||||
$this->app['url']->setRequest($request);
|
||||
|
||||
return app(\App\Http\Controllers\PollController::class)->show($slug);
|
||||
}
|
||||
|
||||
public function test_after_login_the_only_eligible_profile_is_switched_to(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['name' => 'Switch Target Org']);
|
||||
$user->organizations()->attach($organization->id);
|
||||
$this->actAs($user, $user);
|
||||
$poll = $this->makePoll('Organizations only', ['target_profile_types' => [Organization::class]]);
|
||||
$slug = $this->slugOf($poll);
|
||||
$direct = route('organization.direct-login', ['organizationId' => $organization->id])
|
||||
. '?intended=' . urlencode(url('/en/poll/' . $slug));
|
||||
|
||||
// A link with ?switch=1 alone does not switch (security audit R2)
|
||||
$this->assertFalse($this->pollResponse($slug, '?switch=1') instanceof \Illuminate\Http\RedirectResponse);
|
||||
|
||||
// Back from the login (one-time marker): straight to the organization's own login link, which returns here
|
||||
session(['poll_switch_after_login' => '/en/poll/' . $slug]);
|
||||
$response = $this->pollResponse($slug);
|
||||
$this->assertTrue($response->isRedirect());
|
||||
$this->assertSame($direct, $response->getTargetUrl());
|
||||
$this->assertNull(session('poll_switch_after_login')); // used once
|
||||
|
||||
// A normal visit offers it instead of redirecting
|
||||
$html = $this->pollResponse($slug)->render();
|
||||
$this->assertStringContainsString('Take part as Switch Target Org', $html);
|
||||
$this->assertStringContainsString(e($direct), $html);
|
||||
|
||||
// As the organization: no switch offered, the form is there
|
||||
$this->actAs($organization, $user);
|
||||
session(['poll_switch_after_login' => '/en/poll/' . $slug]);
|
||||
$html = $this->pollResponse($slug)->render();
|
||||
$this->assertStringNotContainsString('Take part as', $html);
|
||||
}
|
||||
|
||||
public function test_several_eligible_profiles_are_offered_not_switched_to(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['name' => 'Choice Org']);
|
||||
$bank = \App\Models\Bank::factory()->create(['name' => 'Choice Bank']);
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
$this->actAs($user, $user);
|
||||
$poll = $this->makePoll('Orgs and banks', ['target_profile_types' => [Organization::class, \App\Models\Bank::class]]);
|
||||
|
||||
session(['poll_switch_after_login' => '/en/poll/' . $this->slugOf($poll)]);
|
||||
$response = $this->pollResponse($this->slugOf($poll));
|
||||
|
||||
$html = $response->render();
|
||||
$this->assertStringContainsString('Take part as Choice Org', $html);
|
||||
$this->assertStringContainsString('Take part as Choice Bank', $html);
|
||||
$this->assertStringContainsString(e(route('bank.direct-login', ['bankId' => $bank->id])), $html);
|
||||
}
|
||||
|
||||
public function test_no_switch_without_an_eligible_profile(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
$poll = $this->makePoll('Banks only', ['target_profile_types' => [\App\Models\Bank::class]]);
|
||||
|
||||
session(['poll_switch_after_login' => '/en/poll/' . $this->slugOf($poll)]);
|
||||
$html = $this->pollResponse($this->slugOf($poll))->render();
|
||||
|
||||
$this->assertStringNotContainsString('Take part as', $html);
|
||||
}
|
||||
|
||||
public function test_login_response_returns_to_a_poll_page(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
foreach (['/en/poll/some-poll', '/nl/peiling/een-peiling', '/de/umfrage/x', '/es/encuesta/x', '/fr/sondage/x'] as $path) {
|
||||
session(['url.intended' => url($path)]);
|
||||
$response = app(\App\Http\Responses\LoginResponse::class)->toResponse(request());
|
||||
$this->assertSame(url($path), $response->getTargetUrl(), $path);
|
||||
$this->assertSame($path, session()->pull('poll_switch_after_login'), $path);
|
||||
}
|
||||
|
||||
// Other pages still go to the main page
|
||||
session(['url.intended' => url('/en/polls/1/results.csv')]);
|
||||
$response = app(\App\Http\Responses\LoginResponse::class)->toResponse(request());
|
||||
$this->assertNotSame(url('/en/polls/1/results.csv'), $response->getTargetUrl());
|
||||
}
|
||||
|
||||
public function test_poll_page_shows_post_and_form(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
$poll = $this->makePoll('Dedicated page poll');
|
||||
PostTranslation::where('post_id', $poll->post_id)->update(['content' => '<p>Rich <strong>text</strong></p><script>alert(1)</script>']);
|
||||
|
||||
$html = $this->pollPage($this->slugOf($poll));
|
||||
|
||||
$this->assertStringContainsString('Dedicated page poll', $html);
|
||||
$this->assertStringContainsString('Intro of Dedicated page poll', $html);
|
||||
$this->assertStringContainsString('Rich <strong>text</strong>', $html);
|
||||
$this->assertStringNotContainsString('<script>alert(1)</script>', $html);
|
||||
$this->assertStringContainsString('Question of Dedicated page poll', $html);
|
||||
$this->assertStringContainsString('Submit vote', $html);
|
||||
}
|
||||
|
||||
public function test_unpublished_poll_page_is_only_for_post_admins(): void
|
||||
{
|
||||
$poll = $this->makePoll('Hidden poll', [], null);
|
||||
$slug = $this->slugOf($poll);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
$this->assertStringNotContainsString('Hidden poll', $this->pollPage($slug));
|
||||
|
||||
$this->actAsAdmin();
|
||||
$this->assertStringContainsString('Hidden poll', $this->pollPage($slug));
|
||||
}
|
||||
|
||||
public function test_draft_poll_is_not_found_even_with_a_published_post(): void
|
||||
{
|
||||
// Published post, poll still a draft (security audit R3)
|
||||
$poll = $this->makePoll('Draft with published post', ['status' => Poll::STATUS_DRAFT]);
|
||||
$slug = $this->slugOf($poll);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
$this->assertStringNotContainsString('Draft with published post', $this->pollPage($slug));
|
||||
|
||||
$this->actAsAdmin();
|
||||
$this->assertStringContainsString('Draft with published post', $this->pollPage($slug));
|
||||
}
|
||||
|
||||
public function test_only_admin_profiles_preview_unpublished_polls(): void
|
||||
{
|
||||
// An organization whose role somehow has poll rights still gets no preview (security audit R4)
|
||||
$poll = $this->makePoll('Unpublished for orgs', [], null);
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
$role = Role::firstOrCreate(['name' => "Organization\\{$organization->id}\\preview-role", 'guard_name' => 'web']);
|
||||
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'manage polls', 'guard_name' => 'web']));
|
||||
$user->assignRole($role);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$this->actAs($organization, $user);
|
||||
|
||||
$this->assertTrue(\Illuminate\Support\Facades\Gate::allows('access polls')); // the permission is there...
|
||||
$this->assertStringNotContainsString('Unpublished for orgs', $this->pollPage($this->slugOf($poll))); // ...but no preview
|
||||
}
|
||||
|
||||
public function test_post_pages_of_a_poll_redirect_to_the_poll_page(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user, $user);
|
||||
$poll = $this->makePoll('Redirected poll');
|
||||
$slug = $this->slugOf($poll);
|
||||
$posts = app(\App\Http\Controllers\PostController::class);
|
||||
|
||||
$bySlug = $posts->showBySlug($slug);
|
||||
$this->assertInstanceOf(\Illuminate\Http\RedirectResponse::class, $bySlug);
|
||||
$this->assertSame(route('poll.show', ['slug' => $slug]), $bySlug->getTargetUrl());
|
||||
|
||||
$byId = $posts->showById($poll->post_id);
|
||||
$this->assertInstanceOf(\Illuminate\Http\RedirectResponse::class, $byId);
|
||||
$this->assertSame(route('poll.show', ['slug' => $slug]), $byId->getTargetUrl());
|
||||
|
||||
// Not a poll: not found on the poll route
|
||||
$this->assertStringNotContainsString('Redirected poll', $this->pollPage('no-such-slug'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollResultsExportController;
|
||||
use App\Http\Livewire\Polls\Manage;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Category;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Every poll action against every poll permission (security audit, round 2).
|
||||
* 'manage polls' implies all verbs; 'access polls' (page entry) comes from view or manage.
|
||||
*/
|
||||
class PollPermissionMatrixTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private const ALL = ['view polls', 'create polls', 'update polls', 'export polls', 'delete polls', 'manage polls'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array', 'scout.driver' => null]);
|
||||
foreach (self::ALL as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
Category::firstOrCreate(['type' => Poll::class, 'categoryable_id' => null, 'categoryable_type' => null]);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
private function actAsAdminWith(array $permissions): Admin
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
if ($permissions) {
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\matrix-role", 'guard_name' => 'web']);
|
||||
$role->syncPermissions($permissions);
|
||||
$user->assignRole($role);
|
||||
}
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
private function makePoll(string $status, bool $withVote = false): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId(['postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('post_translations')->insert(['post_id' => $postId, 'locale' => 'en', 'slug' => 'matrix-' . $postId, 'title' => 'Matrix poll ' . $postId, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => $status, 'anonymous' => false]);
|
||||
PollQuestion::create(['poll_id' => $poll->id, 'position' => 1, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => []]);
|
||||
if ($withVote) {
|
||||
$voter = User::factory()->create(['name' => 'matrixvoter' . $poll->id]);
|
||||
PollResponse::create(['poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now()]);
|
||||
}
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: array, 1: array<string, bool>}> permissions => expected outcome per action */
|
||||
public static function matrix(): array
|
||||
{
|
||||
// page create update export delete archive
|
||||
$row = fn ($p, $c, $u, $e, $d, $a) => ['page' => $p, 'create' => $c, 'update' => $u, 'export' => $e, 'delete' => $d, 'archive' => $a];
|
||||
|
||||
return [
|
||||
'no poll permission' => [[], $row(false, false, false, false, false, false)],
|
||||
'view polls' => [['view polls'], $row(true, false, false, false, false, false)],
|
||||
'view + create' => [['view polls', 'create polls'], $row(true, true, false, false, false, false)],
|
||||
'view + update' => [['view polls', 'update polls'], $row(true, false, true, false, false, true)],
|
||||
'view + export' => [['view polls', 'export polls'], $row(true, false, false, true, false, false)],
|
||||
'view + delete' => [['view polls', 'delete polls'], $row(true, false, false, false, true, false)],
|
||||
'manage polls' => [['manage polls'], $row(true, true, true, true, true, true)],
|
||||
// Posts rights give no access to polls
|
||||
'manage posts only' => [['manage posts'], $row(false, false, false, false, false, false)],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider matrix */
|
||||
public function test_permission_matrix(array $permissions, array $expected): void
|
||||
{
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$this->actAsAdminWith($permissions);
|
||||
|
||||
// Page entry (mount)
|
||||
Livewire::test(Manage::class)->assertStatus($expected['page'] ? 200 : 403);
|
||||
if (!$expected['page']) {
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create
|
||||
$before = Poll::count();
|
||||
$create = Livewire::test(Manage::class)->call('openCreateModal');
|
||||
if ($expected['create']) {
|
||||
$create->set('newPoll.title', 'Matrix created poll')->call('createPoll')->assertHasNoErrors();
|
||||
$this->assertSame($before + 1, Poll::count(), 'create');
|
||||
} else {
|
||||
$create->assertStatus(403);
|
||||
$this->assertSame($before, Poll::count(), 'create');
|
||||
}
|
||||
|
||||
// Update
|
||||
$poll = $this->makePoll(Poll::STATUS_DRAFT);
|
||||
$update = Livewire::test(Manage::class)->call('openEditModal', $poll->id)->set('form.results_visibility', Poll::RESULTS_AFTER_CLOSE)->call('savePoll');
|
||||
$this->assertSame($expected['update'] ? Poll::RESULTS_AFTER_CLOSE : Poll::RESULTS_NEVER, $poll->fresh()->results_visibility, 'update');
|
||||
|
||||
// Who voted (Results tab) and the CSV export
|
||||
$voted = $this->makePoll(Poll::STATUS_CLOSED, true);
|
||||
$results = Livewire::test(Manage::class)->call('openEditModal', $voted->id)->set('tab', 'results');
|
||||
$expected['export'] ? $results->assertSee('matrixvoter' . $voted->id) : $results->assertDontSee('matrixvoter' . $voted->id);
|
||||
$this->assertExportAllowed($expected['export'], $voted);
|
||||
|
||||
// Delete (a draft without votes)
|
||||
$draft = $this->makePoll(Poll::STATUS_DRAFT);
|
||||
$delete = Livewire::test(Manage::class)->call('confirmDelete', $draft->id);
|
||||
if ($expected['delete']) {
|
||||
$delete->call('deletePoll');
|
||||
$this->assertSoftDeleted('polls', ['id' => $draft->id]);
|
||||
} else {
|
||||
$delete->assertStatus(403); // refused at the confirmation already
|
||||
$this->assertNotSoftDeleted('polls', ['id' => $draft->id]);
|
||||
}
|
||||
|
||||
// Archive (a closed poll)
|
||||
$closed = $this->makePoll(Poll::STATUS_CLOSED, true);
|
||||
Livewire::test(Manage::class)->call('archivePoll', $closed->id);
|
||||
$this->assertSame($expected['archive'], $closed->fresh()->archived_at !== null, 'archive');
|
||||
}
|
||||
|
||||
private function assertExportAllowed(bool $allowed, Poll $poll): void
|
||||
{
|
||||
try {
|
||||
(new PollResultsExportController())($poll);
|
||||
$this->assertTrue($allowed, 'export should have been refused');
|
||||
} catch (HttpException $e) {
|
||||
$this->assertFalse($allowed, 'export should have been allowed');
|
||||
$this->assertSame(403, $e->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_admin_profiles_get_nothing_even_with_poll_permissions(): void
|
||||
{
|
||||
// An organization whose role somehow carries 'manage polls'
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
$role = Role::firstOrCreate(['name' => "Organization\\{$organization->id}\\matrix-role", 'guard_name' => 'web']);
|
||||
$role->syncPermissions(['manage polls']);
|
||||
$user->assignRole($role);
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
|
||||
// A plain user
|
||||
$plain = User::factory()->create();
|
||||
$this->actingAs($plain, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $plain->id]);
|
||||
Livewire::test(Manage::class)->assertStatus(403);
|
||||
$this->assertExportAllowed(false, $this->makePoll(Poll::STATUS_CLOSED, true));
|
||||
}
|
||||
|
||||
public function test_routes_require_poll_access(): void
|
||||
{
|
||||
$routes = app('router')->getRoutes();
|
||||
$this->assertContains('user.can:access polls', $routes->getByName('polls.manage')->gatherMiddleware());
|
||||
$this->assertContains('user.can:access polls', $routes->getByName('polls.export')->gatherMiddleware());
|
||||
}
|
||||
|
||||
public function test_database_update_gives_manage_polls_to_every_admin_role(): void
|
||||
{
|
||||
foreach (['view posts', 'manage posts'] as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$definition = \App\Models\AdminRole::firstOrCreate(['name' => 'matrix-type'], ['label' => 'Matrix', 'permissions' => ['view posts']]);
|
||||
$admin = Admin::factory()->create();
|
||||
$typed = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\matrix-type", 'guard_name' => 'web']);
|
||||
$typed->syncPermissions(['view posts']);
|
||||
$legacy = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\admin", 'guard_name' => 'web']);
|
||||
$legacy->syncPermissions(['manage posts']);
|
||||
$organizationRole = Role::firstOrCreate(['name' => 'Organization\\999999\\organization-manager', 'guard_name' => 'web']);
|
||||
|
||||
$command = app(\App\Console\Commands\DatabaseUpdate::class);
|
||||
$command->setOutput(new \Illuminate\Console\OutputStyle(new \Symfony\Component\Console\Input\ArrayInput([]), new \Symfony\Component\Console\Output\NullOutput()));
|
||||
$method = new \ReflectionMethod($command, 'addPollPermissions');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($command);
|
||||
$method->invoke($command); // safe to run again
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$this->assertContains('manage polls', $definition->fresh()->permissions);
|
||||
$this->assertContains('view posts', $definition->fresh()->permissions); // others kept
|
||||
$this->assertSame(1, collect($definition->fresh()->permissions)->filter(fn ($p) => $p === 'manage polls')->count());
|
||||
$this->assertTrue($typed->fresh()->hasPermissionTo('manage polls'));
|
||||
$this->assertTrue($legacy->fresh()->hasPermissionTo('manage polls'));
|
||||
$this->assertTrue($legacy->fresh()->hasPermissionTo('manage posts')); // added, not re-synced
|
||||
$this->assertFalse($organizationRole->fresh()->hasPermissionTo('manage polls')); // admin roles only
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Posts\Manage as PostsManage;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The Posts page cannot delete or (un)publish the post of an opened poll (security audit R1).
|
||||
*/
|
||||
class PollPostsPageTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array', 'scout.driver' => null]);
|
||||
}
|
||||
|
||||
private function actAsPostsAdmin(array $extra = []): void
|
||||
{
|
||||
$permissions = array_merge(['view posts', 'delete posts', 'publish posts', 'unpublish posts'], $extra);
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user);
|
||||
$role = Role::firstOrCreate(['name' => "Admin\\{$admin->id}\\posts-only", 'guard_name' => 'web']);
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
}
|
||||
$role->syncPermissions($permissions);
|
||||
$user->assignRole($role);
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
}
|
||||
|
||||
/** @return array{0: ?Poll, 1: int} the poll (if any) and the translation id */
|
||||
private function makePost(?string $pollStatus, bool $withVote = false): array
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId(['postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$translationId = DB::table('post_translations')->insertGetId([
|
||||
'post_id' => $postId, 'locale' => 'en', 'slug' => 'posts-page-' . $postId, 'title' => 'Post ' . $postId,
|
||||
'from' => now()->subDay(), 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = $pollStatus ? Poll::create(['post_id' => $postId, 'status' => $pollStatus]) : null;
|
||||
if ($poll && $withVote) {
|
||||
$voter = User::factory()->create();
|
||||
PollResponse::create(['poll_id' => $poll->id, 'voter_id' => $voter->id, 'voter_type' => User::class, 'user_id' => $voter->id,
|
||||
'dedupe_key' => $poll->dedupeKey($voter, $voter->id), 'submitted_at' => now()]);
|
||||
}
|
||||
|
||||
return [$poll, $translationId];
|
||||
}
|
||||
|
||||
public function test_an_open_poll_post_cannot_be_unpublished_or_deleted(): void
|
||||
{
|
||||
$this->actAsPostsAdmin(['manage polls']);
|
||||
[$poll, $translationId] = $this->makePost(Poll::STATUS_OPEN, true);
|
||||
|
||||
Livewire::test(PostsManage::class)->call('stopPublication', $translationId);
|
||||
$this->assertNull(PostTranslation::find($translationId)->till);
|
||||
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNotNull(PostTranslation::find($translationId));
|
||||
$this->assertNotNull(Post::find($poll->post_id));
|
||||
}
|
||||
|
||||
public function test_a_closed_poll_post_cannot_be_republished(): void
|
||||
{
|
||||
$this->actAsPostsAdmin();
|
||||
[, $translationId] = $this->makePost(Poll::STATUS_CLOSED, true);
|
||||
PostTranslation::whereKey($translationId)->update(['till' => now()->subHour()]);
|
||||
$till = PostTranslation::find($translationId)->till;
|
||||
|
||||
Livewire::test(PostsManage::class)->call('startPublication', $translationId);
|
||||
$this->assertEquals($till, PostTranslation::find($translationId)->till);
|
||||
}
|
||||
|
||||
public function test_a_draft_poll_post_needs_delete_polls_to_be_deleted(): void
|
||||
{
|
||||
$this->actAsPostsAdmin();
|
||||
[$draft, $translationId] = $this->makePost(Poll::STATUS_DRAFT);
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNotNull(PostTranslation::find($translationId));
|
||||
|
||||
$this->actAsPostsAdmin(['delete polls']);
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNull(PostTranslation::find($translationId));
|
||||
$this->assertNull(Post::find($draft->post_id));
|
||||
}
|
||||
|
||||
public function test_other_posts_are_unaffected(): void
|
||||
{
|
||||
$this->actAsPostsAdmin();
|
||||
[, $translationId] = $this->makePost(null);
|
||||
|
||||
Livewire::test(PostsManage::class)->call('stopPublication', $translationId);
|
||||
$this->assertNotNull(PostTranslation::find($translationId)->till);
|
||||
|
||||
Livewire::test(PostsManage::class)->set('bulkSelected', [$translationId])->call('deleteSelected');
|
||||
$this->assertNull(PostTranslation::find($translationId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Who a poll is shown to: open window, profile type, location, last login,
|
||||
* registration date, member scope, and whether the voter already voted.
|
||||
*/
|
||||
class PollTargetingTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private int $country;
|
||||
private int $otherCountry;
|
||||
private int $division;
|
||||
private int $city;
|
||||
private int $otherCity;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
// A small location hierarchy: country > division > two cities, plus another country
|
||||
$this->country = DB::table('countries')->insertGetId(['code' => 'XA', 'flag' => '', 'phonecode' => '9901']);
|
||||
$this->otherCountry = DB::table('countries')->insertGetId(['code' => 'XB', 'flag' => '', 'phonecode' => '9902']);
|
||||
$this->division = DB::table('divisions')->insertGetId(['country_id' => $this->country]);
|
||||
$this->city = DB::table('cities')->insertGetId(['country_id' => $this->country, 'division_id' => $this->division]);
|
||||
$this->otherCity = DB::table('cities')->insertGetId(['country_id' => $this->country, 'division_id' => $this->division]);
|
||||
}
|
||||
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1,
|
||||
'postable_type' => Admin::class,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
}
|
||||
|
||||
private function locate(Model $profile, int $country, ?int $division = null, ?int $city = null): Model
|
||||
{
|
||||
$profile->locations()->save(new Location([
|
||||
'country_id' => $country,
|
||||
'division_id' => $division,
|
||||
'city_id' => $city,
|
||||
]));
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
private function giveRole(User $user, Model $profile, string $role): void
|
||||
{
|
||||
$name = class_basename($profile) . '\\' . $profile->id . '\\' . $role;
|
||||
$user->assignRole(Role::firstOrCreate(['name' => $name, 'guard_name' => 'web']));
|
||||
}
|
||||
|
||||
public function test_untargeted_open_poll_is_shown_to_everyone(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($user, $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Bank::factory()->create(), $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Admin::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_closed_draft_or_out_of_window_poll_is_hidden(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($this->makePoll(['status' => Poll::STATUS_DRAFT])->isTargetedAt($user, $user));
|
||||
$this->assertFalse($this->makePoll(['status' => Poll::STATUS_CLOSED])->isTargetedAt($user, $user));
|
||||
$this->assertFalse($this->makePoll(['opens_at' => now()->addHour()])->isTargetedAt($user, $user));
|
||||
$this->assertFalse($this->makePoll(['closes_at' => now()->subHour()])->isTargetedAt($user, $user));
|
||||
}
|
||||
|
||||
public function test_open_now_scope_matches_is_open(): void
|
||||
{
|
||||
$open = $this->makePoll(['opens_at' => now()->subHour(), 'closes_at' => now()->addHour()]);
|
||||
$this->makePoll(['status' => Poll::STATUS_DRAFT]);
|
||||
$this->makePoll(['closes_at' => now()->subMinute()]);
|
||||
$this->makePoll(['opens_at' => now()->addMinute()]);
|
||||
|
||||
$ids = Poll::openNow()->pluck('id');
|
||||
$this->assertTrue($ids->contains($open->id));
|
||||
$this->assertCount(1, $ids->intersect(Poll::latest('id')->take(4)->pluck('id')));
|
||||
}
|
||||
|
||||
public function test_profile_type_targeting(): void
|
||||
{
|
||||
$poll = $this->makePoll(['target_profile_types' => [Organization::class]]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertFalse($poll->isTargetedAt($user, $user));
|
||||
$this->assertTrue($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
$this->assertFalse($poll->isTargetedAt(Bank::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_location_targeting_uses_deepest_selected_level(): void
|
||||
{
|
||||
$inCity = $this->locate(User::factory()->create(), $this->country, $this->division, $this->city);
|
||||
$inOtherCity = $this->locate(User::factory()->create(), $this->country, $this->division, $this->otherCity);
|
||||
$abroad = $this->locate(User::factory()->create(), $this->otherCountry);
|
||||
$nowhere = User::factory()->create();
|
||||
|
||||
$byCountry = $this->makePoll(['filter_by_location' => true, 'location_country_ids' => [$this->country]]);
|
||||
$this->assertTrue($byCountry->isTargetedAt($inCity, $inCity));
|
||||
$this->assertTrue($byCountry->isTargetedAt($inOtherCity, $inOtherCity));
|
||||
$this->assertFalse($byCountry->isTargetedAt($abroad, $abroad));
|
||||
$this->assertFalse($byCountry->isTargetedAt($nowhere, $nowhere));
|
||||
|
||||
// Country and city selected: the city wins
|
||||
$byCity = $this->makePoll([
|
||||
'filter_by_location' => true,
|
||||
'location_country_ids' => [$this->country],
|
||||
'location_city_ids' => [$this->city],
|
||||
]);
|
||||
$this->assertTrue($byCity->isTargetedAt($inCity, $inCity));
|
||||
$this->assertFalse($byCity->isTargetedAt($inOtherCity, $inOtherCity));
|
||||
|
||||
// Location targeting switched off ignores the stored ids
|
||||
$off = $this->makePoll(['filter_by_location' => false, 'location_city_ids' => [$this->city]]);
|
||||
$this->assertTrue($off->isTargetedAt($abroad, $abroad));
|
||||
}
|
||||
|
||||
public function test_location_targeting_works_for_organizations(): void
|
||||
{
|
||||
$organization = $this->locate(Organization::factory()->create(), $this->country, $this->division, $this->city);
|
||||
$user = User::factory()->create();
|
||||
$poll = $this->makePoll(['filter_by_location' => true, 'location_division_ids' => [$this->division]]);
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($organization, $user));
|
||||
$this->assertFalse($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_last_login_range_is_inclusive_and_excludes_never_logged_in(): void
|
||||
{
|
||||
$poll = $this->makePoll(['last_login_from' => '2026-09-01', 'last_login_until' => '2026-09-30']);
|
||||
|
||||
$first = User::factory()->create(['last_login_at' => '2026-09-01 00:05:00']);
|
||||
$last = User::factory()->create(['last_login_at' => '2026-09-30 23:55:00']);
|
||||
$before = User::factory()->create(['last_login_at' => '2026-08-31 23:59:00']);
|
||||
$never = User::factory()->create(['last_login_at' => null]);
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($first, $first));
|
||||
$this->assertTrue($poll->isTargetedAt($last, $last));
|
||||
$this->assertFalse($poll->isTargetedAt($before, $before));
|
||||
$this->assertFalse($poll->isTargetedAt($never, $never));
|
||||
}
|
||||
|
||||
public function test_registration_range(): void
|
||||
{
|
||||
$poll = $this->makePoll(['registered_until' => '2025-12-31']);
|
||||
|
||||
$old = User::factory()->create();
|
||||
$old->forceFill(['created_at' => '2025-06-01 12:00:00'])->saveQuietly();
|
||||
$new = User::factory()->create();
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($old, $old));
|
||||
$this->assertFalse($poll->isTargetedAt($new, $new));
|
||||
|
||||
$organization = Organization::factory()->create();
|
||||
$this->assertFalse($poll->isTargetedAt($organization, $old));
|
||||
}
|
||||
|
||||
public function test_member_scope_managers_and_coordinators(): void
|
||||
{
|
||||
$poll = $this->makePoll(['member_scope' => Poll::MEMBER_SCOPE_MANAGERS_COORDINATORS]);
|
||||
$organization = Organization::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
[$manager, $coordinator, $plain] = User::factory()->count(3)->create();
|
||||
|
||||
$this->giveRole($manager, $organization, 'organization-manager');
|
||||
$this->giveRole($coordinator, $organization, 'organization-coordinator');
|
||||
$this->giveRole($manager, $bank, 'bank-manager');
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt($organization, $manager));
|
||||
$this->assertTrue($poll->isTargetedAt($organization, $coordinator));
|
||||
$this->assertFalse($poll->isTargetedAt($organization, $plain));
|
||||
$this->assertTrue($poll->isTargetedAt($bank, $manager));
|
||||
$this->assertFalse($poll->isTargetedAt($bank, $coordinator)); // role on another profile
|
||||
|
||||
// A role on a different organization does not count
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$this->assertFalse($poll->isTargetedAt($otherOrganization, $manager));
|
||||
|
||||
// Admin profiles have no manager/coordinator roles: all linked users may vote
|
||||
$this->assertTrue($poll->isTargetedAt(Admin::factory()->create(), $plain));
|
||||
|
||||
// A User profile always votes for itself
|
||||
$this->assertTrue($poll->isTargetedAt($plain, $plain));
|
||||
}
|
||||
|
||||
public function test_member_scope_all_users(): void
|
||||
{
|
||||
$poll = $this->makePoll(['member_scope' => Poll::MEMBER_SCOPE_ALL_USERS]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->assertTrue($poll->isTargetedAt(Organization::factory()->create(), $user));
|
||||
// Organization / Bank / Admin profiles always need the user behind them
|
||||
$this->assertFalse($poll->isTargetedAt(Organization::factory()->create(), null));
|
||||
// A user cannot vote as another User profile
|
||||
$this->assertFalse($poll->isTargetedAt(User::factory()->create(), $user));
|
||||
}
|
||||
|
||||
public function test_response_lookup_and_vote_change(): void
|
||||
{
|
||||
$organization = Organization::factory()->create();
|
||||
[$manager1, $manager2] = User::factory()->count(2)->create();
|
||||
|
||||
$perProfile = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
$this->vote($perProfile, $organization, $manager1);
|
||||
// Any manager now sees the organization's vote
|
||||
$this->assertNotNull($perProfile->responseFor($organization, $manager2->id));
|
||||
$this->assertFalse($perProfile->canSubmit($organization, $manager2->id));
|
||||
|
||||
$perUser = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
$this->vote($perUser, $organization, $manager1);
|
||||
$this->assertFalse($perUser->canSubmit($organization, $manager1->id));
|
||||
$this->assertTrue($perUser->canSubmit($organization, $manager2->id));
|
||||
|
||||
$changeable = $this->makePoll(['allow_vote_change' => true]);
|
||||
$this->vote($changeable, $organization, $manager1);
|
||||
$this->assertTrue($changeable->canSubmit($organization, $manager1->id));
|
||||
}
|
||||
|
||||
private function vote(Poll $poll, Model $profile, User $user): PollResponse
|
||||
{
|
||||
return PollResponse::create([
|
||||
'poll_id' => $poll->id,
|
||||
'voter_id' => $profile->id,
|
||||
'voter_type' => get_class($profile),
|
||||
'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id),
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Livewire\Polls\PollForm;
|
||||
use App\Mail\PollReceiptMail;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Every string the poll code translates exists in all language files.
|
||||
*/
|
||||
class PollTranslationsTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private const LOCALES = ['en', 'nl', 'de', 'es', 'fr'];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function pollStrings(): array
|
||||
{
|
||||
$finder = (new Finder())->files()->in([
|
||||
app_path('Polls'),
|
||||
app_path('Http/Livewire/Polls'),
|
||||
resource_path('views/polls'),
|
||||
resource_path('views/livewire/polls'),
|
||||
resource_path('views/emails/polls'),
|
||||
])->append([app_path('Mail/PollReceiptMail.php'), app_path('Http/Controllers/PollVerificationController.php'), resource_path('views/emails/newsletter/blocks/poll.blade.php')]);
|
||||
|
||||
$strings = [];
|
||||
foreach ($finder as $file) {
|
||||
preg_match_all('/(?:__|trans_choice)\(\s*([\'"])((?:\\\\.|(?!\1).)*)\1/s', file_get_contents($file->getPathname()), $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $match) {
|
||||
$string = stripslashes($match[2]);
|
||||
if (!str_starts_with($string, 'validation.')) {
|
||||
$strings[$string] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Used outside the poll folders: navigation menu and the posts table
|
||||
$strings['Polls'] = true;
|
||||
$strings['Edit poll'] = true;
|
||||
$strings['Editorial'] = true;
|
||||
$strings['Administration'] = true;
|
||||
$strings['Log in to take part in this poll.'] = true;
|
||||
$strings['Log in'] = true;
|
||||
$strings['Poll'] = true;
|
||||
$strings['Read-only'] = true;
|
||||
$strings['Polls created (as draft)'] = true;
|
||||
$strings['Not overwritten: poll has votes'] = true;
|
||||
// Ballot list labels, picked from an array in polls/verify.blade.php
|
||||
foreach (['Vote', 'Changed vote', 'Withdrawal', 'Seal'] as $label) {
|
||||
$strings[$label] = true;
|
||||
}
|
||||
$strings['This post belongs to a poll that is no longer a draft, so its content cannot be changed. Settings of the poll can be changed on the Polls page.'] = true;
|
||||
$strings['Read-only: this poll is no longer a draft'] = true;
|
||||
|
||||
return array_keys($strings);
|
||||
}
|
||||
|
||||
public function test_all_poll_strings_are_translated(): void
|
||||
{
|
||||
$strings = $this->pollStrings();
|
||||
$this->assertGreaterThan(50, count($strings), 'String extraction found too little');
|
||||
|
||||
foreach (self::LOCALES as $locale) {
|
||||
$translations = json_decode(file_get_contents(lang_path("$locale.json")), true, 512, JSON_THROW_ON_ERROR);
|
||||
$missing = array_values(array_diff($strings, array_keys($translations)));
|
||||
$this->assertSame([], $missing, "Missing in $locale.json");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_form_and_receipt_mail_render_in_dutch(): void
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN]);
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'type' => 'yes_no_conditional', 'required' => true,
|
||||
'label' => ['en' => 'Monthly?', 'nl' => 'Maandelijks?'], 'settings' => [],
|
||||
]);
|
||||
$user = User::factory()->create(['lang_preference' => 'nl']);
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
app()->setLocale('nl');
|
||||
Livewire::test(PollForm::class, ['pollId' => $poll->id])
|
||||
->assertSee('Maandelijks?')
|
||||
->assertSee('Misschien, als...')
|
||||
->assertDontSee('Ja, als...')
|
||||
->assertSee('Stem uitbrengen');
|
||||
|
||||
$html = (new PollReceiptMail($poll, $user, 'ABCDE-FGHJK-MNPQR-STUVW'))->render();
|
||||
$this->assertStringContainsString('Dit is je persoonlijke ontvangstcode', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Http\Controllers\PollVerificationController;
|
||||
use App\Http\Livewire\Polls\ReceiptCheck;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The verification page, the public ledger JSON, sealing, the receipt check,
|
||||
* and the browser verifier (run in Node against ledgers built here).
|
||||
*/
|
||||
class PollVerificationTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private Ledger $ledger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
app()->setLocale('en');
|
||||
$this->ledger = new Ledger();
|
||||
}
|
||||
|
||||
/** An open poll with a slot question, a scale 0-2, a ranking and a free text question. */
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $postId, 'locale' => 'en', 'slug' => 'verify-' . $postId, 'title' => 'Verified poll',
|
||||
'from' => '2026-01-01', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN, 'results_visibility' => Poll::RESULTS_AFTER_VOTE], $attributes));
|
||||
|
||||
foreach ([
|
||||
['type' => 'date_time_slots', 'settings' => ['allow_if_needed' => true, 'slots' => [['key' => 'k9', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null], ['key' => 'a1', 'starts_at' => '2026-11-03 10:00', 'ends_at' => null]]]],
|
||||
['type' => 'scale', 'settings' => ['min' => 0, 'max' => 2]],
|
||||
['type' => 'ranking', 'settings' => ['options' => [['key' => 'x', 'label' => ['en' => 'X']], ['key' => 'y', 'label' => ['en' => 'Y']]], 'top_n' => null]],
|
||||
['type' => 'text', 'settings' => ['multiline' => true, 'max_length' => 200]],
|
||||
] as $position => $question) {
|
||||
PollQuestion::create($question + ['poll_id' => $poll->id, 'position' => $position + 1, 'required' => false, 'label' => ['en' => 'Q' . ($position + 1)]]);
|
||||
}
|
||||
|
||||
return $poll->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a vote the way PollForm does: response, answers and ledger entry together.
|
||||
*
|
||||
* @param array<int, array{value: mixed, text: ?string}> $answersByPosition keyed by question position (0-based)
|
||||
*/
|
||||
private function vote(Poll $poll, array $answersByPosition, ?string $receipt = null): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$questions = $poll->questions()->get()->values();
|
||||
$normalized = [];
|
||||
foreach ($answersByPosition as $position => $answer) {
|
||||
$normalized[$questions[$position]->id] = $answer;
|
||||
}
|
||||
$receipt = $receipt ?? $this->ledger->newReceiptCode();
|
||||
|
||||
$response = DB::transaction(function () use ($poll, $user, $normalized, $receipt) {
|
||||
$entry = $this->ledger->append($poll, PollLedgerEntry::CAST, $this->ledger->ballotPayload($normalized), $this->ledger->fingerprint($receipt));
|
||||
$response = PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $user->id, 'voter_type' => User::class, 'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($user, $user->id), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(),
|
||||
]);
|
||||
foreach ($normalized as $questionId => $answer) {
|
||||
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $questionId] + $answer);
|
||||
}
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
return [$response, $receipt];
|
||||
}
|
||||
|
||||
private function populated(): Poll
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$this->vote($poll, [0 => ['value' => ['k9' => 'yes', 'a1' => 'if_needed'], 'text' => null], 1 => ['value' => 0, 'text' => null], 2 => ['value' => ['y', 'x'], 'text' => null], 3 => ['value' => null, 'text' => "Café \u{2028} \"quotes\" / ünïcode"]]);
|
||||
$this->vote($poll, [0 => ['value' => ['a1' => 'yes', 'k9' => 'no'], 'text' => null], 1 => ['value' => 2, 'text' => null], 2 => ['value' => ['x', 'y'], 'text' => null]]);
|
||||
$this->vote($poll, [1 => ['value' => 10 - 9, 'text' => null], 3 => ['value' => null, 'text' => 'short']]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/** Log in as one of the poll's voters (results are visible to them after voting). */
|
||||
private function actAsVoter(Poll $poll): User
|
||||
{
|
||||
$user = User::findOrFail(PollResponse::where('poll_id', $poll->id)->value('user_id'));
|
||||
$this->actAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function actAs(\Illuminate\Database\Eloquent\Model $profile, ?User $user = null): void
|
||||
{
|
||||
$this->actingAs($user ?? $profile, 'web');
|
||||
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]);
|
||||
}
|
||||
|
||||
private function ledgerJson(Poll $poll): array
|
||||
{
|
||||
if (!auth('web')->check()) {
|
||||
$this->actAsVoter($poll);
|
||||
}
|
||||
$response = app(PollVerificationController::class)->ledger($poll, $this->ledger);
|
||||
|
||||
return json_decode($response->getContent(), true);
|
||||
}
|
||||
|
||||
/** Run resources/js/poll-ledger-verifier.js in Node on the given ledger JSON. */
|
||||
private function browserVerify(array $data): array
|
||||
{
|
||||
$node = trim((string) shell_exec('command -v node'));
|
||||
if ($node === '') {
|
||||
$this->markTestSkipped('Node is not installed');
|
||||
}
|
||||
|
||||
$script = 'const fs = require("fs");'
|
||||
. 'eval(fs.readFileSync(process.argv[1], "utf8"));'
|
||||
. 'globalThis.PollLedgerVerifier.verify(JSON.parse(fs.readFileSync(0, "utf8")))'
|
||||
. '.then((r) => process.stdout.write(JSON.stringify(r)));';
|
||||
$process = new Process([$node, '-e', $script, resource_path('js/poll-ledger-verifier.js')]);
|
||||
$process->setInput(json_encode($data));
|
||||
$process->mustRun();
|
||||
|
||||
return json_decode($process->getOutput(), true);
|
||||
}
|
||||
|
||||
private function checks(array $result): array
|
||||
{
|
||||
return array_column($result['checks'], 'ok', 'key');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public function test_browser_verifier_accepts_an_untouched_ledger(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
|
||||
$result = $this->browserVerify($this->ledgerJson($poll));
|
||||
|
||||
$this->assertTrue($result['valid'], json_encode($result));
|
||||
$this->assertSame(['chain' => true, 'recount' => true], $this->checks($result));
|
||||
}
|
||||
|
||||
public function test_browser_verifier_detects_tampering(): void
|
||||
{
|
||||
$data = $this->ledgerJson($this->populated());
|
||||
|
||||
$tampered = $data;
|
||||
$tampered['entries'][1]['payload']['answers'][1]['value'] = 1;
|
||||
$this->assertFalse($this->checks($this->browserVerify($tampered))['chain']);
|
||||
|
||||
$removed = $data;
|
||||
array_splice($removed['entries'], 1, 1);
|
||||
$this->assertFalse($this->checks($this->browserVerify($removed))['chain']);
|
||||
|
||||
// A published result that differs from the ballots
|
||||
$inflated = $data;
|
||||
$inflated['published_tally'][0]['count']++;
|
||||
$result = $this->checks($this->browserVerify($inflated));
|
||||
$this->assertTrue($result['chain']);
|
||||
$this->assertFalse($result['recount']);
|
||||
}
|
||||
|
||||
public function test_seal_and_withdrawal_after_it(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
|
||||
Artisan::call('polls:seal');
|
||||
$poll->refresh();
|
||||
$this->assertNotNull($poll->sealed_at);
|
||||
$seal = PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::SEAL)->sole();
|
||||
$this->assertSame($seal->hash, $poll->seal_hash);
|
||||
$this->assertSame(3, $seal->payload['active_ballots']);
|
||||
|
||||
// Idempotent
|
||||
Artisan::call('polls:seal');
|
||||
$this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::SEAL)->count());
|
||||
|
||||
// A profile deleted after closing: its ballot is withdrawn after the seal, the seal still verifies
|
||||
DB::transaction(fn () => $this->ledger->append($poll, PollLedgerEntry::WITHDRAW, ['reason' => 'profile_deleted'], null, 1));
|
||||
PollResponse::where('poll_id', $poll->id)->where('ledger_sequence', 1)->delete();
|
||||
|
||||
$result = $this->browserVerify($this->ledgerJson($poll));
|
||||
$this->assertTrue($result['valid'], json_encode($result));
|
||||
$this->assertSame(['chain' => true, 'recount' => true, 'seal' => true], $this->checks($result));
|
||||
|
||||
// A different seal hash is caught
|
||||
$data = $this->ledgerJson($poll);
|
||||
$data['seal_hash'] = str_repeat('f', 64);
|
||||
$this->assertFalse($this->checks($this->browserVerify($data))['seal']);
|
||||
}
|
||||
|
||||
public function test_polls_past_closing_time_are_sealed_and_open_ones_not(): void
|
||||
{
|
||||
$ended = $this->makePoll(['closes_at' => now()->subMinute()]);
|
||||
$open = $this->makePoll(['closes_at' => now()->addDay()]);
|
||||
|
||||
Artisan::call('polls:seal');
|
||||
|
||||
$this->assertNotNull($ended->fresh()->sealed_at);
|
||||
$this->assertNull($open->fresh()->sealed_at);
|
||||
}
|
||||
|
||||
public function test_page_shows_state_recount_and_anonymous_ballots(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$this->actAsVoter($poll);
|
||||
|
||||
$html = app(PollVerificationController::class)->show($poll, $this->ledger)->render();
|
||||
|
||||
$this->assertStringContainsString('Verified poll', $html);
|
||||
$this->assertStringContainsString('intact', $html);
|
||||
$this->assertStringContainsString('The recount of the ballots matches the published result.', $html);
|
||||
$this->assertStringContainsString('[text]', $html);
|
||||
$this->assertStringNotContainsString('ünïcode', $html); // free text stays hidden
|
||||
$this->assertStringContainsString('root.PollLedgerVerifier = {', $html); // the verifier code is on the page
|
||||
}
|
||||
|
||||
public function test_receipts_level_hides_the_ballot_list_and_json(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['verification' => Poll::VERIFICATION_RECEIPTS]);
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$html = app(PollVerificationController::class)->show($poll, $this->ledger)->render();
|
||||
$this->assertStringContainsString('Check your vote', $html);
|
||||
$this->assertStringNotContainsString('All ballots', $html);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
app(PollVerificationController::class)->ledger($poll, $this->ledger);
|
||||
}
|
||||
|
||||
public function test_verification_off_or_draft_is_not_found(): void
|
||||
{
|
||||
$off = $this->makePoll(['verification' => Poll::VERIFICATION_OFF]);
|
||||
$this->assertSame('posts.not_found', app(PollVerificationController::class)->show($off, $this->ledger)->name());
|
||||
|
||||
$draft = $this->makePoll(['status' => Poll::STATUS_DRAFT]);
|
||||
$this->assertSame('posts.not_found', app(PollVerificationController::class)->show($draft, $this->ledger)->name());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Who may see what (security audit F1)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function showHtml(Poll $poll): ?string
|
||||
{
|
||||
$view = app(PollVerificationController::class)->show($poll, $this->ledger);
|
||||
|
||||
return $view->name() === 'posts.not_found' ? null : $view->render();
|
||||
}
|
||||
|
||||
private function ledgerAllowed(Poll $poll): bool
|
||||
{
|
||||
try {
|
||||
app(PollVerificationController::class)->ledger($poll, $this->ledger);
|
||||
return true;
|
||||
} catch (NotFoundHttpException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function test_profiles_the_poll_is_not_for_get_not_found(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['target_profile_types' => [\App\Models\Organization::class]]);
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user);
|
||||
|
||||
$this->assertNull($this->showHtml($poll));
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
|
||||
// Its organization may
|
||||
$organization = \App\Models\Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
$this->actAs($organization, $user);
|
||||
$this->assertNotNull($this->showHtml($poll));
|
||||
}
|
||||
|
||||
public function test_unpublished_poll_is_not_found(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
DB::table('post_translations')->where('post_id', $poll->post_id)->update(['from' => now()->addWeek()]);
|
||||
$this->actAsVoter($poll);
|
||||
|
||||
$this->assertNull($this->showHtml($poll));
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
}
|
||||
|
||||
public function test_results_never_hides_the_ballots_from_voters(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['results_visibility' => Poll::RESULTS_NEVER]);
|
||||
$this->actAsVoter($poll);
|
||||
|
||||
$html = $this->showHtml($poll);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringContainsString('Check your vote', $html); // the receipt check stays
|
||||
$this->assertStringNotContainsString('All ballots', $html);
|
||||
$this->assertStringNotContainsString('root.PollLedgerVerifier', $html);
|
||||
$this->assertStringContainsString('when the results of this poll are visible to you', $html);
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
}
|
||||
|
||||
public function test_results_after_vote_only_for_those_who_voted(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$nonVoter = User::factory()->create();
|
||||
$this->actAs($nonVoter);
|
||||
$this->assertStringNotContainsString('All ballots', $this->showHtml($poll));
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
|
||||
$this->actAsVoter($poll);
|
||||
$this->assertStringContainsString('All ballots', $this->showHtml($poll));
|
||||
$this->assertTrue($this->ledgerAllowed($poll));
|
||||
}
|
||||
|
||||
public function test_results_after_close_only_once_closed(): void
|
||||
{
|
||||
$poll = $this->populated();
|
||||
$poll->update(['results_visibility' => Poll::RESULTS_AFTER_CLOSE]);
|
||||
$this->actAsVoter($poll);
|
||||
$this->assertFalse($this->ledgerAllowed($poll));
|
||||
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
$this->assertTrue($this->ledgerAllowed($poll->fresh()));
|
||||
}
|
||||
|
||||
public function test_receipt_checks_are_rate_limited(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->actAs($user);
|
||||
|
||||
$component = Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id]);
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$component->set('code', 'AAAAA-BBBBB-CCCCC-DDDD' . chr(65 + $i))->call('check')->assertHasNoErrors();
|
||||
}
|
||||
$component->set('code', 'AAAAA-BBBBB-CCCCC-DDDDZ')->call('check')
|
||||
->assertHasErrors('code')
|
||||
->assertSee('Too many checks');
|
||||
}
|
||||
|
||||
public function test_receipt_check_shows_the_ballot_and_its_own_text(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
[, $receipt] = $this->vote($poll, [1 => ['value' => 2, 'text' => null], 3 => ['value' => null, 'text' => 'Bring cake']]);
|
||||
|
||||
Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id])
|
||||
->set('code', strtolower($receipt))
|
||||
->call('check')
|
||||
->assertSet('notFound', false)
|
||||
->assertSee('Ballot #1')
|
||||
->assertSee('Counted in the result.')
|
||||
->assertSee('Bring cake')
|
||||
->assertSee('text matches its fingerprint');
|
||||
|
||||
Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id])
|
||||
->set('code', 'AAAAA-BBBBB-CCCCC-DDDDD')
|
||||
->call('check')
|
||||
->assertSet('notFound', true)
|
||||
->assertSee('No ballot with this receipt code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Actions\Jetstream\DeleteUser;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Poll;
|
||||
use App\Models\PollAnswer;
|
||||
use App\Models\PollLedgerEntry;
|
||||
use App\Models\PollQuestion;
|
||||
use App\Models\PollResponse;
|
||||
use App\Models\User;
|
||||
use App\Polls\Ledger;
|
||||
use App\Polls\VoteWithdrawal;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Votes of permanently deleted profiles: identity removed, ballot withdrawn in the ledger.
|
||||
*/
|
||||
class PollVoteWithdrawalTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private Ledger $ledger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array']);
|
||||
$this->ledger = new Ledger();
|
||||
}
|
||||
|
||||
private function makePoll(array $attributes = []): Poll
|
||||
{
|
||||
$postId = DB::table('posts')->insertGetId([
|
||||
'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN], $attributes));
|
||||
PollQuestion::create([
|
||||
'poll_id' => $poll->id, 'type' => 'yes_no', 'label' => ['en' => 'Q'], 'settings' => [],
|
||||
]);
|
||||
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/** Vote the way PollForm does. Returns the receipt code. */
|
||||
private function vote(Poll $poll, Model $profile, User $user, string $value): string
|
||||
{
|
||||
$question = $poll->questions()->first();
|
||||
$receipt = $this->ledger->newReceiptCode();
|
||||
|
||||
DB::transaction(function () use ($poll, $profile, $user, $value, $question, $receipt) {
|
||||
$entry = $this->ledger->append($poll, PollLedgerEntry::CAST,
|
||||
$this->ledger->ballotPayload([$question->id => ['value' => $value, 'text' => null]]),
|
||||
$this->ledger->fingerprint($receipt));
|
||||
$response = PollResponse::create([
|
||||
'poll_id' => $poll->id, 'voter_id' => $profile->getKey(), 'voter_type' => get_class($profile), 'user_id' => $user->id,
|
||||
'dedupe_key' => $poll->dedupeKey($profile, $user->id), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(),
|
||||
]);
|
||||
PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $question->id, 'value' => $value]);
|
||||
});
|
||||
|
||||
return $receipt;
|
||||
}
|
||||
|
||||
public function test_a_users_votes_are_withdrawn_and_the_ledger_stays_consistent(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$leaving = User::factory()->create();
|
||||
$staying = User::factory()->create();
|
||||
$receipt = $this->vote($poll, $leaving, $leaving, 'yes');
|
||||
$this->vote($poll, $staying, $staying, 'no');
|
||||
|
||||
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($leaving));
|
||||
|
||||
$this->assertSame(0, PollResponse::where('voter_id', $leaving->id)->where('voter_type', User::class)->count());
|
||||
$this->assertSame(1, $poll->responses()->count());
|
||||
$withdrawal = PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::WITHDRAW)->sole();
|
||||
$this->assertSame(1, $withdrawal->refers_to_sequence);
|
||||
$this->assertSame('withdrawn', $this->ledger->lookupReceipt($poll, $receipt)['status']);
|
||||
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
|
||||
// The recount still matches the published result
|
||||
$this->assertSame($this->ledger->publishedTally($poll), $this->ledger->ledgerTally($poll));
|
||||
$this->assertSame(1, $poll->questionSummaries()[$poll->questions()->first()->id]['total']);
|
||||
}
|
||||
|
||||
public function test_an_organizations_votes_are_withdrawn(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$organization = Organization::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
$this->vote($poll, $organization, $manager, 'yes');
|
||||
|
||||
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($organization));
|
||||
$this->assertSame(0, $poll->responses()->count());
|
||||
}
|
||||
|
||||
public function test_a_users_own_vote_for_an_organization_goes_only_in_per_user_polls(): void
|
||||
{
|
||||
$perUser = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_USER]);
|
||||
$perProfile = $this->makePoll(['vote_scope' => Poll::VOTE_SCOPE_PER_PROFILE]);
|
||||
$organization = Organization::factory()->create();
|
||||
$manager = User::factory()->create();
|
||||
$this->vote($perUser, $organization, $manager, 'yes');
|
||||
$this->vote($perProfile, $organization, $manager, 'yes');
|
||||
|
||||
$this->assertSame(1, app(VoteWithdrawal::class)->withdrawFor($manager));
|
||||
|
||||
$this->assertSame(0, $perUser->responses()->count());
|
||||
// The organization's vote in a per-profile poll stays
|
||||
$this->assertSame(1, $perProfile->responses()->count());
|
||||
}
|
||||
|
||||
public function test_withdrawal_after_the_seal_keeps_the_chain_valid(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user, 'yes');
|
||||
$poll->update(['status' => Poll::STATUS_CLOSED]);
|
||||
$this->ledger->seal($poll);
|
||||
|
||||
app(VoteWithdrawal::class)->withdrawFor($user);
|
||||
|
||||
$this->assertTrue($this->ledger->verifyChain($poll->fresh())['valid']);
|
||||
$types = PollLedgerEntry::where('poll_id', $poll->id)->orderBy('sequence')->pluck('entry_type')->all();
|
||||
$this->assertSame(['cast', 'seal', 'withdraw'], $types);
|
||||
}
|
||||
|
||||
public function test_permanent_deletion_withdraws_votes(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user, 'yes');
|
||||
|
||||
$withdrawal = \Mockery::mock(VoteWithdrawal::class);
|
||||
$withdrawal->shouldReceive('withdrawFor')->once()->with(\Mockery::on(fn ($profile) => $profile->is($user)))->andReturn(1);
|
||||
$this->app->instance(VoteWithdrawal::class, $withdrawal);
|
||||
|
||||
(new DeleteUser())->permanentlyDelete($user);
|
||||
}
|
||||
|
||||
public function test_a_failed_deletion_keeps_the_votes(): void
|
||||
{
|
||||
$poll = $this->makePoll();
|
||||
$user = User::factory()->create();
|
||||
$this->vote($poll, $user, $user, 'yes');
|
||||
|
||||
// The test database has no debit account, so the balance step fails and the transaction rolls back
|
||||
$result = (new DeleteUser())->permanentlyDelete($user);
|
||||
|
||||
$this->assertSame('error', $result['status']);
|
||||
$this->assertSame(1, $poll->responses()->count());
|
||||
$this->assertSame(0, PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::WITHDRAW)->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Polls\QuestionTypeRegistry;
|
||||
use App\Polls\Types\AbstractQuestionType;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvalidArgumentException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FakeQuestionType extends AbstractQuestionType
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'fake';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Fake type';
|
||||
}
|
||||
|
||||
public function answerRules(string $prefix, array $settings, bool $required): array
|
||||
{
|
||||
return [$prefix . '.value' => $required ? 'required' : 'nullable'];
|
||||
}
|
||||
|
||||
public function aggregate(Collection $answers, array $settings): array
|
||||
{
|
||||
return ['count' => $answers->count()];
|
||||
}
|
||||
}
|
||||
|
||||
class NotAQuestionType
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry reads the enabled question types from the platform config.
|
||||
*/
|
||||
class QuestionTypeRegistryTest extends TestCase
|
||||
{
|
||||
private function registryWith(array $types): QuestionTypeRegistry
|
||||
{
|
||||
config([env('TIMEBANK_CONFIG', 'timebank-default') . '.polls.question_types' => $types]);
|
||||
|
||||
return new QuestionTypeRegistry();
|
||||
}
|
||||
|
||||
public function test_platform_config_without_the_polls_section_gets_all_built_in_types(): void
|
||||
{
|
||||
// A platform config that has not merged the polls section from its .example yet
|
||||
$platform = env('TIMEBANK_CONFIG', 'timebank-default');
|
||||
$settings = config($platform);
|
||||
unset($settings['polls']);
|
||||
config([$platform => $settings]);
|
||||
|
||||
$registry = new QuestionTypeRegistry();
|
||||
|
||||
$this->assertSame(array_keys(QuestionTypeRegistry::DEFAULT_TYPES), array_keys($registry->all()));
|
||||
}
|
||||
|
||||
public function test_an_empty_list_disables_all_types(): void
|
||||
{
|
||||
$this->assertSame([], $this->registryWith([])->all());
|
||||
}
|
||||
|
||||
public function test_registry_is_a_singleton(): void
|
||||
{
|
||||
$this->assertSame(app(QuestionTypeRegistry::class), app(QuestionTypeRegistry::class));
|
||||
}
|
||||
|
||||
public function test_resolves_configured_types(): void
|
||||
{
|
||||
$registry = $this->registryWith(['fake' => FakeQuestionType::class]);
|
||||
|
||||
$this->assertTrue($registry->has('fake'));
|
||||
$this->assertInstanceOf(FakeQuestionType::class, $registry->get('fake'));
|
||||
$this->assertSame(['fake' => 'Fake type'], $registry->options());
|
||||
$this->assertSame('polls.types.fake.input', $registry->get('fake')->inputView());
|
||||
}
|
||||
|
||||
public function test_disabled_type_is_unknown(): void
|
||||
{
|
||||
$registry = $this->registryWith(['fake' => FakeQuestionType::class]);
|
||||
|
||||
$this->assertFalse($registry->has('ranking'));
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$registry->get('ranking');
|
||||
}
|
||||
|
||||
public function test_rejects_class_that_is_not_a_question_type(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->registryWith(['fake' => NotAQuestionType::class])->all();
|
||||
}
|
||||
|
||||
public function test_rejects_key_mismatch(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->registryWith(['other' => FakeQuestionType::class])->all();
|
||||
}
|
||||
|
||||
public function test_base_normalize_trims_text_and_drops_empty(): void
|
||||
{
|
||||
$type = new FakeQuestionType();
|
||||
|
||||
$this->assertSame(['value' => 'yes', 'text' => 'if it rains'], $type->normalize(['value' => 'yes', 'text' => ' if it rains '], []));
|
||||
$this->assertSame(['value' => 'no', 'text' => null], $type->normalize(['value' => 'no', 'text' => ' '], []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Polls;
|
||||
|
||||
use App\Models\PollAnswer;
|
||||
use App\Polls\Contracts\QuestionType;
|
||||
use App\Polls\QuestionTypeRegistry;
|
||||
use App\Polls\Types\DateTimeSlotsType;
|
||||
use App\Polls\Types\MultiChoiceType;
|
||||
use App\Polls\Types\RankingType;
|
||||
use App\Polls\Types\ScaleType;
|
||||
use App\Polls\Types\SingleChoiceType;
|
||||
use App\Polls\Types\TextType;
|
||||
use App\Polls\Types\YesNoConditionalType;
|
||||
use App\Polls\Types\YesNoType;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Validation, normalization and aggregation of the starter question types.
|
||||
*/
|
||||
class QuestionTypesTest extends TestCase
|
||||
{
|
||||
private const PREFIX = 'answers.12';
|
||||
|
||||
private function opts(array $keys): array
|
||||
{
|
||||
return array_map(fn ($key) => ['key' => $key, 'label' => ['en' => strtoupper($key)]], $keys);
|
||||
}
|
||||
|
||||
/** Errors for one answer, keyed by attribute; empty when valid. */
|
||||
private function answerErrors(QuestionType $type, array $settings, $answer, bool $required = true): array
|
||||
{
|
||||
$data = ['answers' => [12 => $answer]];
|
||||
|
||||
return Validator::make($data, $type->answerRules(self::PREFIX, $settings, $required))->errors()->toArray();
|
||||
}
|
||||
|
||||
private function settingsErrors(QuestionType $type, array $settings): array
|
||||
{
|
||||
$prefix = 'questions.0.settings';
|
||||
|
||||
return Validator::make(['questions' => [['settings' => $settings]]], $type->settingsRules($prefix, $settings))->errors()->toArray();
|
||||
}
|
||||
|
||||
/** Unsaved answer models, as aggregate() receives them. */
|
||||
private function answers(QuestionType $type, array $settings, array $raw): Collection
|
||||
{
|
||||
return collect($raw)->map(fn ($answer) => new PollAnswer($type->normalize($answer, $settings)));
|
||||
}
|
||||
|
||||
public function test_all_starter_types_are_registered_with_partials(): void
|
||||
{
|
||||
$types = app(QuestionTypeRegistry::class)->all();
|
||||
|
||||
$this->assertSame(
|
||||
['yes_no', 'yes_no_conditional', 'single_choice', 'multi_choice', 'date_time_slots', 'scale', 'text', 'ranking'],
|
||||
array_keys($types)
|
||||
);
|
||||
foreach ($types as $type) {
|
||||
$this->assertEmpty($this->settingsErrors($type, $this->validDefaults($type)), $type->key() . ' defaults');
|
||||
foreach ([$type->editorView(), $type->inputView(), $type->resultsView()] as $view) {
|
||||
$this->assertTrue(view()->exists($view), "Missing view $view");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_all_partials_render_with_sample_data(): void
|
||||
{
|
||||
$registry = app(QuestionTypeRegistry::class);
|
||||
$raw = [
|
||||
'yes_no' => [['value' => 'yes'], ['value' => 'no']],
|
||||
'yes_no_conditional' => [['value' => 'yes'], ['value' => 'maybe_if', 'text' => 'Only <b>after</b> 18:00']],
|
||||
'single_choice' => [['value' => 'k0']],
|
||||
'multi_choice' => [['value' => ['k0', 'k1']]],
|
||||
'date_time_slots' => [['value' => ['s0' => 'yes']]],
|
||||
'scale' => [['value' => 4]],
|
||||
'text' => [['text' => 'Open <script>answer</script>']],
|
||||
'ranking' => [['value' => ['k1', 'k0']]],
|
||||
];
|
||||
|
||||
foreach ($registry->all() as $key => $type) {
|
||||
$settings = $this->validDefaults($type);
|
||||
foreach ($settings['options'] ?? [] as $i => $option) {
|
||||
$settings['options'][$i]['key'] = "k$i";
|
||||
}
|
||||
foreach ($settings['slots'] ?? [] as $i => $slot) {
|
||||
$settings['slots'][$i]['key'] = "s$i";
|
||||
}
|
||||
$question = new \App\Models\PollQuestion(['type' => $key, 'label' => ['en' => 'Q'], 'settings' => $settings]);
|
||||
$question->id = 12;
|
||||
$errors = (new ViewErrorBag())->put('default', new MessageBag([self::PREFIX . '.value' => ['Value error shown']]));
|
||||
$common = ['type' => $type, 'settings' => $settings, 'locale' => 'en', 'errors' => $errors];
|
||||
|
||||
$editor = view($type->editorView(), $common + ['prefix' => 'questions.0.settings', 'index' => 0])->render();
|
||||
if ($type->defaultSettings() !== []) {
|
||||
$this->assertStringContainsString('questions.0.settings', $editor, "$key editor");
|
||||
}
|
||||
|
||||
$input = view($type->inputView(), $common + ['question' => $question, 'prefix' => self::PREFIX])->render();
|
||||
$this->assertStringContainsString(self::PREFIX, $input, "$key input");
|
||||
if ($key !== 'text') {
|
||||
$this->assertStringContainsString('Value error shown', $input, "$key input error");
|
||||
}
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, $raw[$key]), $settings);
|
||||
$results = view($type->resultsView(), $common + ['question' => $question, 'summary' => $summary])->render();
|
||||
$this->assertMatchesRegularExpression('/\d+ responses?/', $results, "$key results");
|
||||
$this->assertStringNotContainsString('<script>answer', $results, "$key results must escape answers");
|
||||
}
|
||||
}
|
||||
|
||||
/** Defaults with the blanks an admin has to fill in. */
|
||||
private function validDefaults(QuestionType $type): array
|
||||
{
|
||||
$settings = $type->defaultSettings();
|
||||
foreach ($settings['options'] ?? [] as $i => $option) {
|
||||
$settings['options'][$i]['label'] = ['en' => "Option $i"];
|
||||
}
|
||||
foreach ($settings['slots'] ?? [] as $i => $slot) {
|
||||
$settings['slots'][$i]['starts_at'] = '2026-11-01T10:00';
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function test_yes_no_conditional(): void
|
||||
{
|
||||
$type = new YesNoConditionalType();
|
||||
$settings = ['conditional_label' => ['en' => 'Maybe, if it is on a weekday']];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => 'yes']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['value' => 'maybe_if', 'text' => '']));
|
||||
// The old "Yes, if..." and a plain "Maybe" are no longer answers
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 'maybe']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 'yes_if']));
|
||||
$this->assertSame(['yes', 'maybe_if', 'no'], array_keys($type->choices($settings)));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => null]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => null], false));
|
||||
$this->assertSame('Maybe, if it is on a weekday', $type->choices($settings, 'en')['maybe_if']);
|
||||
$this->assertSame('Maybe, if...', $type->choices([], 'en')['maybe_if']);
|
||||
|
||||
// The condition text is dropped for any answer but "Maybe, if..."
|
||||
$this->assertSame(['value' => 'no', 'text' => null], $type->normalize(['value' => 'no', 'text' => 'stale'], $settings));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => 'yes'], ['value' => 'maybe_if', 'text' => 'after 18:00'], ['value' => 'no'], ['value' => null],
|
||||
]), $settings);
|
||||
$this->assertSame(3, $summary['total']);
|
||||
$this->assertSame(['yes' => 1, 'maybe_if' => 1, 'no' => 1], $summary['counts']);
|
||||
$this->assertSame(['after 18:00'], $summary['conditions']);
|
||||
}
|
||||
|
||||
public function test_yes_no_without_maybe_if(): void
|
||||
{
|
||||
$type = new YesNoType();
|
||||
|
||||
$this->assertSame(['yes', 'no'], array_keys($type->choices([])));
|
||||
$this->assertEmpty($this->answerErrors($type, [], ['value' => 'no']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, [], ['value' => 'maybe_if', 'text' => 'if it rains']));
|
||||
$this->assertSame(['value' => 'yes', 'text' => null], $type->normalize(['value' => 'yes', 'text' => 'stray'], []));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, [], [['value' => 'yes'], ['value' => 'yes'], ['value' => 'no']]), []);
|
||||
$this->assertSame(['yes' => 2, 'no' => 1], $summary['counts']);
|
||||
$this->assertSame([], $summary['conditions']);
|
||||
}
|
||||
|
||||
public function test_single_choice_with_other(): void
|
||||
{
|
||||
$type = new SingleChoiceType();
|
||||
$settings = ['options' => $this->opts(['a', 'b']), 'allow_other' => true];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => 'a']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 'z']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['value' => '__other']));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => '__other', 'text' => 'Sunday']));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => 'a'], ['value' => 'a'], ['value' => '__other', 'text' => 'Sunday'],
|
||||
]), $settings);
|
||||
$this->assertSame(['a' => 2, 'b' => 0, '__other' => 1], $summary['counts']);
|
||||
$this->assertSame(['Sunday'], $summary['other_texts']);
|
||||
}
|
||||
|
||||
public function test_options_need_two_distinct_labelled_items(): void
|
||||
{
|
||||
$type = new SingleChoiceType();
|
||||
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => $this->opts(['a'])]));
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => $this->opts(['a', 'a'])]));
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => [
|
||||
['key' => 'a', 'label' => ['en' => 'A']], ['key' => 'b', 'label' => ['en' => ' ']],
|
||||
]]));
|
||||
// A label in any one language is enough
|
||||
$this->assertEmpty($this->settingsErrors($type, ['options' => [
|
||||
['key' => 'a', 'label' => ['nl' => 'A']], ['key' => 'b', 'label' => ['de' => 'B']],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function test_multi_choice_limits_and_other(): void
|
||||
{
|
||||
$type = new MultiChoiceType();
|
||||
$settings = ['options' => $this->opts(['a', 'b', 'c']), 'allow_other' => true, 'min_selections' => 2, 'max_selections' => 2];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['a', 'b']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['a']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['a', 'b', 'c']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => []]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => []], false));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['value' => ['a', '__other']]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['a', '__other'], 'text' => 'D']));
|
||||
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['options' => $this->opts(['a', 'b']), 'min_selections' => 2, 'max_selections' => 1]));
|
||||
// A maximum without a minimum is fine
|
||||
$this->assertEmpty($this->settingsErrors($type, ['options' => $this->opts(['a', 'b']), 'min_selections' => null, 'max_selections' => 1]));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => ['a', 'b']], ['value' => ['a', '__other'], 'text' => 'D'], ['value' => []],
|
||||
]), $settings);
|
||||
$this->assertSame(2, $summary['total']);
|
||||
$this->assertSame(['a' => 2, 'b' => 1, 'c' => 0, '__other' => 1], $summary['counts']);
|
||||
$this->assertSame(['D'], $summary['other_texts']);
|
||||
}
|
||||
|
||||
public function test_date_time_slots(): void
|
||||
{
|
||||
$type = new DateTimeSlotsType();
|
||||
$settings = ['allow_if_needed' => true, 'slots' => [
|
||||
['key' => 's1', 'starts_at' => '2026-11-02T10:00', 'ends_at' => '2026-11-02T12:00'],
|
||||
['key' => 's2', 'starts_at' => '2026-11-03T14:00', 'ends_at' => null],
|
||||
]];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['s1' => 'yes', 's2' => 'if_needed']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.s2', $this->answerErrors($type, $settings, ['value' => ['s1' => 'yes']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.s1', $this->answerErrors($type, $settings, ['value' => ['s1' => 'perhaps', 's2' => 'no']]));
|
||||
$this->assertSame('Monday 2 November 2026, 10:00 – 12:00', $type->slotLabels($settings, 'en')['s1']);
|
||||
|
||||
$this->assertNotEmpty($this->settingsErrors($type, ['slots' => [
|
||||
['key' => 's1', 'starts_at' => '2026-11-02T10:00', 'ends_at' => '2026-11-02T09:00'],
|
||||
]]));
|
||||
|
||||
// Unknown slot keys are dropped
|
||||
$this->assertSame(['s1' => 'yes'], $type->normalize(['value' => ['s1' => 'yes', 'x' => 'yes']], $settings)['value']);
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => ['s1' => 'yes', 's2' => 'yes']],
|
||||
['value' => ['s1' => 'if_needed', 's2' => 'yes']],
|
||||
['value' => ['s1' => 'yes', 's2' => 'no']],
|
||||
]), $settings);
|
||||
$this->assertSame(['yes' => 2, 'if_needed' => 1, 'no' => 0], $summary['slots']['s1']);
|
||||
$this->assertSame(['s1'], $summary['best']);
|
||||
}
|
||||
|
||||
public function test_scale(): void
|
||||
{
|
||||
$type = new ScaleType();
|
||||
$settings = ['min' => 1, 'max' => 5];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => 5]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 6]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => 0]));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [['value' => '4'], ['value' => 5], ['value' => '']]), $settings);
|
||||
$this->assertSame(2, $summary['total']);
|
||||
$this->assertSame(4.5, $summary['average']);
|
||||
$this->assertSame([1 => 0, 2 => 0, 3 => 0, 4 => 1, 5 => 1], $summary['distribution']);
|
||||
}
|
||||
|
||||
public function test_text(): void
|
||||
{
|
||||
$type = new TextType();
|
||||
$settings = ['multiline' => true, 'max_length' => 20];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['text' => 'Short answer']));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['text' => str_repeat('x', 21)]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.text', $this->answerErrors($type, $settings, ['text' => '']));
|
||||
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [['text' => ' One '], ['text' => ' ']]), $settings);
|
||||
$this->assertSame(['total' => 1, 'texts' => ['One']], $summary);
|
||||
}
|
||||
|
||||
public function test_ranking_borda_count(): void
|
||||
{
|
||||
$type = new RankingType();
|
||||
$settings = ['options' => $this->opts(['a', 'b', 'c']), 'top_n' => null];
|
||||
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['b', 'a', 'c']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['b', 'a']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.1', $this->answerErrors($type, $settings, ['value' => ['b', 'b', 'c']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value.2', $this->answerErrors($type, $settings, ['value' => ['b', 'a', 'z']]));
|
||||
|
||||
// 3 options: 1st = 3 points, 2nd = 2, 3rd = 1
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [
|
||||
['value' => ['b', 'a', 'c']], ['value' => ['a', 'b', 'c']], ['value' => ['b', 'c', 'a']],
|
||||
]), $settings);
|
||||
$this->assertSame(['b' => 8, 'a' => 6, 'c' => 4], $summary['points']);
|
||||
$this->assertSame(['a' => 1, 'b' => 2, 'c' => 0], $summary['first_place']);
|
||||
}
|
||||
|
||||
public function test_ranking_top_n(): void
|
||||
{
|
||||
$type = new RankingType();
|
||||
$settings = ['options' => $this->opts(['a', 'b', 'c', 'd']), 'top_n' => 2];
|
||||
|
||||
$this->assertSame(2, $type->rankCount($settings));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => ['d', 'a']]));
|
||||
$this->assertArrayHasKey(self::PREFIX . '.value', $this->answerErrors($type, $settings, ['value' => ['d', 'a', 'b']]));
|
||||
$this->assertEmpty($this->answerErrors($type, $settings, ['value' => []], false));
|
||||
|
||||
// Unranked options score 0; 1st of 4 = 4 points, 2nd = 3
|
||||
$summary = $type->aggregate($this->answers($type, $settings, [['value' => ['d', 'a']]]), $settings);
|
||||
$this->assertSame(['d' => 4, 'a' => 3, 'b' => 0, 'c' => 0], $summary['points']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PostContentXssProtectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test that the sanitizeHtml method properly escapes script tags.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_escapes_script_tags()
|
||||
{
|
||||
$maliciousContent = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// Script tags should be completely removed
|
||||
$this->assertStringNotContainsString('<script>', $sanitized);
|
||||
$this->assertStringNotContainsString('alert("XSS")', $sanitized);
|
||||
|
||||
// Safe HTML should be preserved
|
||||
$this->assertStringContainsString('<p>Hello</p>', $sanitized);
|
||||
$this->assertStringContainsString('<p>World</p>', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that img tag with onerror handler is sanitized.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_sanitizes_img_onerror()
|
||||
{
|
||||
$maliciousContent = '<p>Check this image</p><img src="x" onerror="alert(document.cookie)" />';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// Image should be allowed but without dangerous attributes
|
||||
$this->assertStringContainsString('<img', $sanitized);
|
||||
$this->assertStringNotContainsString('onerror', $sanitized);
|
||||
$this->assertStringNotContainsString('alert(document.cookie)', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that iframe injection is completely removed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_removes_iframe()
|
||||
{
|
||||
$maliciousContent = '<p>Content</p><iframe src="https://evil.com"></iframe><p>More content</p>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// Iframe should be completely removed
|
||||
$this->assertStringNotContainsString('<iframe', $sanitized);
|
||||
$this->assertStringNotContainsString('evil.com', $sanitized);
|
||||
|
||||
// Safe content preserved
|
||||
$this->assertStringContainsString('<p>Content</p>', $sanitized);
|
||||
$this->assertStringContainsString('<p>More content</p>', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that safe rich text formatting is preserved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_preserves_safe_formatting()
|
||||
{
|
||||
$safeContent = '<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul><a href="https://example.com">Link</a>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($safeContent);
|
||||
|
||||
// All safe formatting should be preserved
|
||||
$this->assertStringContainsString('<h1>Title</h1>', $sanitized);
|
||||
$this->assertStringContainsString('<strong>bold</strong>', $sanitized);
|
||||
$this->assertStringContainsString('<em>italic</em>', $sanitized);
|
||||
$this->assertStringContainsString('<ul>', $sanitized);
|
||||
$this->assertStringContainsString('<li>Item 1</li>', $sanitized);
|
||||
$this->assertStringContainsString('<a href="https://example.com">Link</a>', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that event handler attributes are removed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_removes_event_handlers()
|
||||
{
|
||||
$maliciousContent = '<a href="#" onclick="alert(1)">Click me</a><div onmouseover="steal()">Hover</div>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// Event handlers should be removed
|
||||
$this->assertStringNotContainsString('onclick', $sanitized);
|
||||
$this->assertStringNotContainsString('onmouseover', $sanitized);
|
||||
$this->assertStringNotContainsString('alert(1)', $sanitized);
|
||||
$this->assertStringNotContainsString('steal()', $sanitized);
|
||||
|
||||
// Safe parts should remain (div is allowed with class attribute)
|
||||
$this->assertStringContainsString('Click me', $sanitized);
|
||||
$this->assertStringContainsString('Hover', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that data URIs with JavaScript are removed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_removes_javascript_data_uris()
|
||||
{
|
||||
$maliciousContent = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// JavaScript data URIs should be removed
|
||||
$this->assertStringNotContainsString('data:text/html', $sanitized);
|
||||
$this->assertStringNotContainsString('<script>', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that style tags with CSS injection are removed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_removes_style_tags()
|
||||
{
|
||||
$maliciousContent = '<p>Text</p><style>body { background: url("javascript:alert(1)") }</style>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// Style tags should be removed
|
||||
$this->assertStringNotContainsString('<style>', $sanitized);
|
||||
$this->assertStringNotContainsString('javascript:alert', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that object and embed tags are removed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_removes_object_embed_tags()
|
||||
{
|
||||
$maliciousContent = '<object data="malicious.swf"></object><embed src="evil.swf">';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// Object and embed tags should be removed
|
||||
$this->assertStringNotContainsString('<object', $sanitized);
|
||||
$this->assertStringNotContainsString('<embed', $sanitized);
|
||||
$this->assertStringNotContainsString('malicious.swf', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that null input returns empty string.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_handles_null_input()
|
||||
{
|
||||
$sanitized = StringHelper::sanitizeHtml(null);
|
||||
|
||||
$this->assertEquals('', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that empty string returns empty string.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_handles_empty_string()
|
||||
{
|
||||
$sanitized = StringHelper::sanitizeHtml('');
|
||||
|
||||
$this->assertEquals('', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that table HTML is preserved for structured content.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_preserves_tables()
|
||||
{
|
||||
$tableContent = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody></table>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($tableContent);
|
||||
|
||||
// Table structure should be preserved
|
||||
$this->assertStringContainsString('<table>', $sanitized);
|
||||
$this->assertStringContainsString('<thead>', $sanitized);
|
||||
$this->assertStringContainsString('<th>Header</th>', $sanitized);
|
||||
$this->assertStringContainsString('<tbody>', $sanitized);
|
||||
$this->assertStringContainsString('<td>Data</td>', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that images with valid attributes are preserved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_preserves_safe_images()
|
||||
{
|
||||
$imageContent = '<img src="https://example.com/image.jpg" alt="Description" width="500" height="300" title="My Image">';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($imageContent);
|
||||
|
||||
// Valid image attributes should be preserved
|
||||
$this->assertStringContainsString('src="https://example.com/image.jpg"', $sanitized);
|
||||
$this->assertStringContainsString('alt="Description"', $sanitized);
|
||||
$this->assertStringContainsString('width="500"', $sanitized);
|
||||
$this->assertStringContainsString('height="300"', $sanitized);
|
||||
$this->assertStringContainsString('title="My Image"', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that links with target="_blank" are preserved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_preserves_target_blank_links()
|
||||
{
|
||||
$linkContent = '<a href="https://example.com" target="_blank" title="External Link">Visit</a>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($linkContent);
|
||||
|
||||
// Link with target="_blank" should be preserved
|
||||
$this->assertStringContainsString('href="https://example.com"', $sanitized);
|
||||
$this->assertStringContainsString('target="_blank"', $sanitized);
|
||||
$this->assertStringContainsString('title="External Link"', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test mixed safe and unsafe content.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_handles_mixed_content()
|
||||
{
|
||||
$mixedContent = '<h2>Article Title</h2><p>This is <strong>safe</strong> content.</p><script>alert("unsafe")</script><p>More safe content with <a href="https://example.com">a link</a>.</p>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($mixedContent);
|
||||
|
||||
// Safe content preserved
|
||||
$this->assertStringContainsString('<h2>Article Title</h2>', $sanitized);
|
||||
$this->assertStringContainsString('<strong>safe</strong>', $sanitized);
|
||||
$this->assertStringContainsString('<a href="https://example.com">a link</a>', $sanitized);
|
||||
|
||||
// Unsafe content removed
|
||||
$this->assertStringNotContainsString('<script>', $sanitized);
|
||||
$this->assertStringNotContainsString('alert("unsafe")', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that code and pre tags are preserved for technical content.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_preserves_code_blocks()
|
||||
{
|
||||
$codeContent = '<pre><code>function hello() { return "world"; }</code></pre>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($codeContent);
|
||||
|
||||
// Code blocks should be preserved
|
||||
$this->assertStringContainsString('<pre>', $sanitized);
|
||||
$this->assertStringContainsString('<code>', $sanitized);
|
||||
$this->assertStringContainsString('function hello()', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SVG injection attempts are blocked.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_post_content_blocks_svg_injection()
|
||||
{
|
||||
$maliciousContent = '<svg onload="alert(1)"><circle cx="50" cy="50" r="40"/></svg>';
|
||||
|
||||
$sanitized = StringHelper::sanitizeHtml($maliciousContent);
|
||||
|
||||
// SVG should be removed (not in allowed tags list)
|
||||
$this->assertStringNotContainsString('<svg', $sanitized);
|
||||
$this->assertStringNotContainsString('onload', $sanitized);
|
||||
$this->assertStringNotContainsString('alert(1)', $sanitized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\AcceptPrinciples;
|
||||
use App\Http\Middleware\EnsurePrinciplesAccepted;
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* A member who must (re)accept the principles first is sent on to the page they were going
|
||||
* to after accepting, e.g. a poll opened from a mailing on their first login.
|
||||
*/
|
||||
class PrinciplesIntendedUrlTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// phpunit.xml configures Redis, which is the dev site's cache too
|
||||
config(['cache.default' => 'array', 'scout.driver' => null]);
|
||||
|
||||
$category = Category::firstOrCreate(['type' => 'SiteContents\Static\Principles']);
|
||||
$post = Post::withoutSyncingToSearch(fn () => Post::create(['postable_id' => 1, 'postable_type' => \App\Models\Admin::class, 'category_id' => $category->id]));
|
||||
DB::table('post_translations')->insert([
|
||||
'post_id' => $post->id, 'locale' => 'en', 'slug' => 'principles-intended-test-' . $post->id, 'title' => 'Principles',
|
||||
'status' => 1, 'from' => now()->subDay(), 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_the_requested_page_is_remembered_before_the_principles(): void
|
||||
{
|
||||
$user = User::factory()->create(['principles_terms_accepted' => null]);
|
||||
$this->actingAs($user, 'web');
|
||||
$request = Request::create(url('/en/poll/some-poll?switch=1'));
|
||||
$request->setLaravelSession($this->app['session.store']);
|
||||
|
||||
$response = (new EnsurePrinciplesAccepted())->handle($request, fn () => response('passed'));
|
||||
|
||||
$this->assertTrue($response->isRedirect());
|
||||
$this->assertStringContainsString('principles', $response->headers->get('Location'));
|
||||
$this->assertSame(url('/en/poll/some-poll?switch=1'), session('principles_intended_url'));
|
||||
}
|
||||
|
||||
public function test_livewire_requests_are_not_remembered(): void
|
||||
{
|
||||
$user = User::factory()->create(['principles_terms_accepted' => null]);
|
||||
$this->actingAs($user, 'web');
|
||||
$request = Request::create(url('/livewire/update'), 'POST');
|
||||
$request->headers->set('X-Livewire', 'true');
|
||||
$request->setLaravelSession($this->app['session.store']);
|
||||
|
||||
(new EnsurePrinciplesAccepted())->handle($request, fn () => response('passed'));
|
||||
|
||||
$this->assertNull(session('principles_intended_url'));
|
||||
}
|
||||
|
||||
public function test_accepting_continues_to_the_remembered_page(): void
|
||||
{
|
||||
$user = User::factory()->create(['principles_terms_accepted' => null]);
|
||||
$this->actingAs($user, 'web');
|
||||
session(['principles_intended_url' => url('/en/poll/some-poll?switch=1')]);
|
||||
|
||||
Livewire::test(AcceptPrinciples::class)
|
||||
->set('agreed', true)
|
||||
->call('accept')
|
||||
->assertRedirect(url('/en/poll/some-poll?switch=1'));
|
||||
|
||||
$this->assertNotNull($user->fresh()->principles_terms_accepted);
|
||||
$this->assertNull(session('principles_intended_url'));
|
||||
}
|
||||
|
||||
public function test_accepting_without_a_remembered_page_stays(): void
|
||||
{
|
||||
$user = User::factory()->create(['principles_terms_accepted' => null]);
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
Livewire::test(AcceptPrinciples::class)
|
||||
->set('agreed', true)
|
||||
->call('accept')
|
||||
->assertNoRedirect();
|
||||
|
||||
$this->assertNotNull($user->fresh()->principles_terms_accepted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered()
|
||||
{
|
||||
$response = $this->get(route('register'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration runs through the Livewire Registration component. Fortify's POST /register
|
||||
* created users with only CreateNewUser's minimal rules (e.g. a reserved name like "admin"),
|
||||
* so it must not be reachable.
|
||||
*/
|
||||
public function test_fortify_register_endpoint_cannot_create_users()
|
||||
{
|
||||
// A real client can get a CSRF token from any page
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'admin',
|
||||
'email' => 'bypass@example.com',
|
||||
'password' => 'Password-12345!',
|
||||
'password_confirmation' => 'Password-12345!',
|
||||
]);
|
||||
|
||||
$this->assertContains($response->status(), [404, 405]);
|
||||
$this->assertDatabaseMissing('users', ['email' => 'bypass@example.com']);
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\SingleReport;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Guards the reciprocity rate against small-denominator distortion.
|
||||
*
|
||||
* The rate was a plain mean of per-partner percentages, so a partner given 6
|
||||
* minutes who returned 1.941 scored 32.350% and outvoted partners representing
|
||||
* hundreds of hours. A real organization's report showed 414,5% when its actual
|
||||
* return was 93%. The number looked plausible, which is why it went unnoticed.
|
||||
*/
|
||||
class ReturnRatioTest extends TestCase
|
||||
{
|
||||
// The network tests below insert fixtures; roll them back afterwards.
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// An in-memory cache: phpunit.xml configures Redis, which is the dev
|
||||
// site's cache too, and the network graph built here caches the TEST
|
||||
// database's transaction type names there for ever (see
|
||||
// StatsAggregationTest::setUp).
|
||||
config(['cache.default' => 'array']);
|
||||
}
|
||||
|
||||
/** @return array{weighted: float, median: float, partner_count: int} */
|
||||
private function summarise(array $profileRatios): array
|
||||
{
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'summariseReturnRatios');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(new SingleReport(), $profileRatios);
|
||||
}
|
||||
|
||||
public function test_one_tiny_partnership_cannot_dominate_the_rate(): void
|
||||
{
|
||||
// Two partners of real size returning ~50%, and one trivial partnership
|
||||
// returning 100x what it was given. The mean of the three percentages is
|
||||
// over 11.000%; the honest answer is near 50%.
|
||||
$summary = $this->summarise([
|
||||
'User:1' => ['given' => 6000, 'received' => 3000],
|
||||
'User:2' => ['given' => 4000, 'received' => 2000],
|
||||
'User:3' => ['given' => 6, 'received' => 600],
|
||||
]);
|
||||
|
||||
// 5.600 received of 10.006 given.
|
||||
$this->assertEqualsWithDelta(55.96, $summary['weighted'], 0.01);
|
||||
|
||||
$unweightedMean = (50 + 50 + 10000) / 3;
|
||||
$this->assertLessThan(
|
||||
$unweightedMean / 10,
|
||||
$summary['weighted'],
|
||||
'A trivial partnership must not be able to inflate the rate.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_weighted_rate_is_total_received_over_total_given(): void
|
||||
{
|
||||
$summary = $this->summarise([
|
||||
'User:1' => ['given' => 120, 'received' => 60],
|
||||
'User:2' => ['given' => 240, 'received' => 300],
|
||||
]);
|
||||
|
||||
// 360 of 360.
|
||||
$this->assertEqualsWithDelta(100.0, $summary['weighted'], 0.001);
|
||||
}
|
||||
|
||||
public function test_median_reports_the_typical_partnership(): void
|
||||
{
|
||||
// 21 partners: one extreme, twenty at a steady rate. The median ignores
|
||||
// the outlier that would drag a mean.
|
||||
$ratios = ['User:0' => ['given' => 1, 'received' => 500]];
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$ratios["User:$i"] = ['given' => 100, 'received' => 50];
|
||||
}
|
||||
|
||||
$summary = $this->summarise($ratios);
|
||||
|
||||
$this->assertEqualsWithDelta(50.0, $summary['median'], 0.001);
|
||||
$this->assertSame(21, $summary['partner_count']);
|
||||
}
|
||||
|
||||
public function test_median_averages_the_middle_pair_when_even(): void
|
||||
{
|
||||
// 20 partners, evenly spread 5%..100% in steps of 5. The middle pair is
|
||||
// 50% and 55%, so the median is 52,5%.
|
||||
$ratios = [];
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$ratios["User:$i"] = ['given' => 100, 'received' => $i * 5];
|
||||
}
|
||||
|
||||
$summary = $this->summarise($ratios);
|
||||
|
||||
$this->assertEqualsWithDelta(52.5, $summary['median'], 0.001);
|
||||
$this->assertSame(20, $summary['partner_count']);
|
||||
}
|
||||
|
||||
public function test_median_is_withheld_below_the_partner_threshold(): void
|
||||
{
|
||||
// The TTT case: two partners at 43,5% and 872,5%. An "even count"
|
||||
// median averages the middle pair — which at two partners is both of
|
||||
// them — so it printed 458%, a number carrying no information at all.
|
||||
$summary = $this->summarise([
|
||||
'User:1' => ['given' => 4370, 'received' => 1901],
|
||||
'User:2' => ['given' => 345, 'received' => 3010],
|
||||
]);
|
||||
|
||||
$this->assertNull($summary['median'], 'Too few partners for a median to mean anything.');
|
||||
$this->assertEqualsWithDelta(104.2, $summary['weighted'], 0.1);
|
||||
}
|
||||
|
||||
public function test_partners_who_were_never_given_anything_are_excluded(): void
|
||||
{
|
||||
// Nothing given means no return to measure — and dividing by it would
|
||||
// blow up. Such a partner must not count toward the median either.
|
||||
$summary = $this->summarise([
|
||||
'User:1' => ['given' => 100, 'received' => 50],
|
||||
'User:2' => ['given' => 0, 'received' => 900],
|
||||
]);
|
||||
|
||||
$this->assertEqualsWithDelta(50.0, $summary['weighted'], 0.001);
|
||||
$this->assertSame(1, $summary['partner_count']);
|
||||
}
|
||||
|
||||
public function test_banks_are_not_reciprocity_partners(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'isReciprocityPartner');
|
||||
$method->setAccessible(true);
|
||||
$report = new SingleReport();
|
||||
|
||||
// A bank issuing currency, reversing a payment or migrating a legacy
|
||||
// balance is not reciprocating. Counting one made TTT — whose single
|
||||
// real partner returned 43,5% — report 104,2%.
|
||||
$this->assertFalse($method->invoke($report, \App\Models\Bank::class . ':6'));
|
||||
$this->assertTrue($method->invoke($report, \App\Models\User::class . ':161'));
|
||||
$this->assertTrue($method->invoke($report, \App\Models\Organization::class . ':11'));
|
||||
$this->assertFalse($method->invoke($report, null));
|
||||
}
|
||||
|
||||
public function test_balance_distribution_is_offered_to_banks_and_organizations_only(): void
|
||||
{
|
||||
// Banks hold many counterparties, so the bands describe a real spread —
|
||||
// the same reason organizations qualify. A user's partner list is small
|
||||
// enough that the partner threshold would withhold it regardless, but
|
||||
// the type gate makes that explicit rather than incidental.
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'balanceDistribution');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$report = new SingleReport();
|
||||
$report->fromDate = now()->subYear()->format('Y-m-d');
|
||||
$report->toDate = now()->format('Y-m-d');
|
||||
|
||||
$this->assertNull(
|
||||
$method->invoke($report, new \App\Models\User()),
|
||||
'A user report must not offer a partner balance distribution.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_withheld_distribution_is_distinguishable_from_absent(): void
|
||||
{
|
||||
// Two different "no chart" cases the view must tell apart: a user has no
|
||||
// such section at all (null), while an organization below the partner
|
||||
// threshold has one that is deliberately withheld — and says so, because
|
||||
// a section that simply vanishes reads as a bug.
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'balanceDistribution');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$report = new SingleReport();
|
||||
$report->fromDate = now()->subYear()->format('Y-m-d');
|
||||
$report->toDate = now()->format('Y-m-d');
|
||||
|
||||
$this->assertNull(
|
||||
$method->invoke($report, new \App\Models\User()),
|
||||
'A user report has no partner distribution section at all.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_no_partners_reports_zero_rather_than_dividing_by_zero(): void
|
||||
{
|
||||
$summary = $this->summarise([]);
|
||||
|
||||
$this->assertSame(0.0, $summary['weighted']);
|
||||
$this->assertNull($summary['median']);
|
||||
$this->assertSame(0, $summary['partner_count']);
|
||||
}
|
||||
|
||||
/** A bank plus two client users that trade with each other. */
|
||||
private function seedBankWithClients(): array
|
||||
{
|
||||
$bankId = \DB::table('banks')->insertGetId([
|
||||
'name' => 'net_test_bank',
|
||||
'full_name' => 'net_test_bank',
|
||||
'password' => bcrypt('secret'),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$users = [];
|
||||
foreach (['net_a', 'net_b'] as $name) {
|
||||
$users[$name] = \DB::table('users')->insertGetId([
|
||||
'name' => $name,
|
||||
'full_name' => $name,
|
||||
'email' => $name . '@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'created_at' => now()->subYear(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
\DB::table('bank_clients')->insert([
|
||||
'bank_id' => $bankId,
|
||||
'client_type' => \App\Models\User::class,
|
||||
'client_id' => $users[$name],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// An outsider: not a client of this bank, but trading with one.
|
||||
$users['net_out'] = \DB::table('users')->insertGetId([
|
||||
'name' => 'net_outsider',
|
||||
'full_name' => 'net_outsider',
|
||||
'email' => 'net_outsider@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'created_at' => now()->subYear(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$accounts = [];
|
||||
foreach ($users as $key => $id) {
|
||||
$accounts[$key] = \DB::table('accounts')->insertGetId([
|
||||
'name' => 'current',
|
||||
'limit_min' => 0,
|
||||
'limit_max' => 0,
|
||||
'accountable_type' => \App\Models\User::class,
|
||||
'accountable_id' => $id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tx = function ($from, $to) use ($accounts) {
|
||||
\DB::table('transactions')->insert([
|
||||
'from_account_id' => $accounts[$from],
|
||||
'to_account_id' => $accounts[$to],
|
||||
'amount' => 60,
|
||||
'transaction_type_id' => 1,
|
||||
'description' => 'network fixture',
|
||||
'created_at' => now()->subMonth(),
|
||||
'updated_at' => now()->subMonth(),
|
||||
]);
|
||||
};
|
||||
|
||||
$tx('net_a', 'net_b');
|
||||
$tx('net_b', 'net_a');
|
||||
$tx('net_a', 'net_out'); // crosses the circle boundary
|
||||
|
||||
return ['bank' => $bankId, 'users' => $users];
|
||||
}
|
||||
|
||||
public function test_report_network_is_offered_to_banks_only(): void
|
||||
{
|
||||
// A bank administers its clients' accounts, so seeing who among them
|
||||
// trades with whom is within its remit. An organization or user report
|
||||
// would expose other members' trade, which is not theirs to see.
|
||||
$fixture = $this->seedBankWithClients();
|
||||
$report = new SingleReport();
|
||||
|
||||
session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]);
|
||||
$this->assertTrue($report->showsNetwork());
|
||||
|
||||
session(['activeProfileType' => \App\Models\User::class, 'activeProfileId' => $fixture['users']['net_a']]);
|
||||
$this->assertFalse($report->showsNetwork(), 'A user report must not show the network.');
|
||||
}
|
||||
|
||||
public function test_report_network_scope_is_limited_to_the_banks_clients(): void
|
||||
{
|
||||
// The boundary that stops a bank seeing names of non-client members.
|
||||
$fixture = $this->seedBankWithClients();
|
||||
$report = new SingleReport();
|
||||
session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]);
|
||||
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'networkScope');
|
||||
$method->setAccessible(true);
|
||||
$scope = $method->invoke($report);
|
||||
|
||||
$this->assertCount(2, $scope, 'Only the two clients belong to the scope.');
|
||||
$this->assertContains(\App\Models\User::class . ':' . $fixture['users']['net_a'], $scope);
|
||||
$this->assertNotContains(
|
||||
\App\Models\User::class . ':' . $fixture['users']['net_out'],
|
||||
$scope,
|
||||
'The outsider is not a client of this bank.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_report_network_marks_other_bank_networks(): void
|
||||
{
|
||||
// Outsiders ARE named now, on the operator's instruction: a bank sees
|
||||
// who its clients trade with beyond its own circle. What still has to
|
||||
// hold is that such a node is recognisable as belonging to another
|
||||
// network — through the outside flag (grey, dashed in the UI) and the
|
||||
// bank label in the tooltip.
|
||||
$fixture = $this->seedBankWithClients();
|
||||
$report = new SingleReport();
|
||||
session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]);
|
||||
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'networkScope');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$graph = (new \App\Services\StatsService(
|
||||
new \App\Services\Stats\StatsFilter(allTime: true)
|
||||
))->network(1, $method->invoke($report), true, ['User', 'Organization']);
|
||||
|
||||
$outsiders = array_filter($graph['nodes'], fn($n) => $n['outside'] ?? false);
|
||||
|
||||
$this->assertNotEmpty($outsiders, 'Without outsiders this test proves nothing.');
|
||||
|
||||
foreach ($outsiders as $node) {
|
||||
$this->assertTrue($node['outside'], 'An outsider must stay flagged as such.');
|
||||
$this->assertNotSame('', $node['name'], 'Outsiders are named now.');
|
||||
// The bank field exists on every node; it is null for a profile
|
||||
// that belongs to no bank at all.
|
||||
$this->assertArrayHasKey('bank', $node);
|
||||
}
|
||||
|
||||
// The outsider in the fixture belongs to no bank, so its label is null
|
||||
// rather than another bank's name.
|
||||
$outsider = collect($outsiders)->firstWhere('name', 'net_outsider');
|
||||
$this->assertNotNull($outsider, 'The outsider should appear by name.');
|
||||
$this->assertNull($outsider['bank']);
|
||||
}
|
||||
|
||||
public function test_report_network_reports_the_bank_of_each_client(): void
|
||||
{
|
||||
// The tooltip needs to say which network a node belongs to; for the
|
||||
// bank's own clients that is the bank itself.
|
||||
$fixture = $this->seedBankWithClients();
|
||||
$report = new SingleReport();
|
||||
session(['activeProfileType' => \App\Models\Bank::class, 'activeProfileId' => $fixture['bank']]);
|
||||
|
||||
$method = new \ReflectionMethod(SingleReport::class, 'networkScope');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$graph = (new \App\Services\StatsService(
|
||||
new \App\Services\Stats\StatsFilter(allTime: true)
|
||||
))->network(1, $method->invoke($report), true, ['User', 'Organization']);
|
||||
|
||||
$client = collect($graph['nodes'])->firstWhere('name', 'net_a');
|
||||
|
||||
$this->assertNotNull($client);
|
||||
$this->assertSame('net_test_bank', $client['bank']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Livewire\MainSearchBar;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SearchXssProtectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test that the sanitizeHighlights method properly escapes XSS attempts.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_escape_script_tags()
|
||||
{
|
||||
// Create a test user
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
// Authenticate as the user
|
||||
$this->actingAs($user);
|
||||
|
||||
// Create MainSearchBar component instance
|
||||
$component = new MainSearchBar();
|
||||
|
||||
// Use reflection to access the private sanitizeHighlights method
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Test 1: Simple script tag injection
|
||||
$maliciousHighlights = [
|
||||
'about_short_en' => [
|
||||
'I am a developer <script>alert("XSS")</script> looking for work'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $maliciousHighlights);
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $sanitized['about_short_en'][0]);
|
||||
$this->assertStringContainsString('<script>', $sanitized['about_short_en'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that img tag with onerror handler is escaped.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_escape_img_onerror()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$maliciousHighlights = [
|
||||
'about_en' => [
|
||||
'Contact me <img src=x onerror=alert(document.cookie)> for projects'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $maliciousHighlights);
|
||||
|
||||
// The img tag should be escaped
|
||||
$this->assertStringNotContainsString('<img', $sanitized['about_en'][0]);
|
||||
$this->assertStringContainsString('<img', $sanitized['about_en'][0]);
|
||||
// The equals sign in the attribute should be escaped, making it safe
|
||||
$this->assertStringContainsString('src=x', $sanitized['about_en'][0]); // Text 'onerror' may remain but is harmless when HTML is escaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that iframe injection is escaped.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_escape_iframe()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$maliciousHighlights = [
|
||||
'motivation_en' => [
|
||||
'My motivation <iframe src="https://evil.com"></iframe> is strong'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $maliciousHighlights);
|
||||
|
||||
$this->assertStringNotContainsString('<iframe', $sanitized['motivation_en'][0]);
|
||||
$this->assertStringContainsString('<iframe', $sanitized['motivation_en'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that Elasticsearch highlight tags are preserved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_preserve_elasticsearch_tags()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Simulate Elasticsearch highlight with configured tags
|
||||
$validHighlights = [
|
||||
'name' => [
|
||||
'John <span class="font-semibold text-white leading-tight">Smith</span>'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $validHighlights);
|
||||
|
||||
// Elasticsearch highlight tags should be preserved
|
||||
$this->assertStringContainsString('<span class="font-semibold text-white leading-tight">Smith</span>', $sanitized['name'][0]);
|
||||
$this->assertStringContainsString('John', $sanitized['name'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that user content with special characters is escaped but highlight tags preserved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_escape_user_content_preserve_highlight_tags()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// User content with XSS attempt + Elasticsearch highlight tags
|
||||
$mixedHighlights = [
|
||||
'about_short_en' => [
|
||||
'I love <span class="font-semibold text-white leading-tight">coding</span> <script>alert("XSS")</script>'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $mixedHighlights);
|
||||
|
||||
// Elasticsearch tags preserved
|
||||
$this->assertStringContainsString('<span class="font-semibold text-white leading-tight">coding</span>', $sanitized['about_short_en'][0]);
|
||||
|
||||
// User XSS attempt escaped
|
||||
$this->assertStringNotContainsString('<script>alert("XSS")</script>', $sanitized['about_short_en'][0]);
|
||||
$this->assertStringContainsString('<script>', $sanitized['about_short_en'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that empty highlights array is handled correctly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_handle_empty_array()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$emptyHighlights = [];
|
||||
$sanitized = $method->invoke($component, $emptyHighlights);
|
||||
|
||||
$this->assertEmpty($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that event handler attributes are escaped.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_escape_event_handlers()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$maliciousHighlights = [
|
||||
'about_en' => [
|
||||
'Click <a href="#" onclick="alert(1)">here</a> for more info'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $maliciousHighlights);
|
||||
|
||||
// The anchor tag and attributes should be escaped
|
||||
$this->assertStringNotContainsString('<a href', $sanitized['about_en'][0]);
|
||||
$this->assertStringContainsString('<a href="#"', $sanitized['about_en'][0]);
|
||||
// Verify the dangerous onclick is escaped (quotes are converted to ")
|
||||
$this->assertStringContainsString('onclick="', $sanitized['about_en'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that data URIs with JavaScript are escaped.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_search_highlights_escape_data_uris()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = new MainSearchBar();
|
||||
$reflection = new \ReflectionClass($component);
|
||||
$method = $reflection->getMethod('sanitizeHighlights');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$maliciousHighlights = [
|
||||
'about_en' => [
|
||||
'Visit <a href="data:text/html,<script>alert(1)</script>">link</a>'
|
||||
]
|
||||
];
|
||||
|
||||
$sanitized = $method->invoke($component, $maliciousHighlights);
|
||||
|
||||
// The anchor tag should be escaped, making the data URI harmless
|
||||
$this->assertStringNotContainsString('<a href="data:text/html', $sanitized['about_en'][0]);
|
||||
$this->assertStringContainsString('<a', $sanitized['about_en'][0]);
|
||||
// Verify the script tag within the data URI is also escaped
|
||||
$this->assertStringContainsString('<script>', $sanitized['about_en'][0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Direct Login Routes Security Tests
|
||||
*
|
||||
* Tests the security of direct login routes used in emails and external links:
|
||||
* - /user/{userId}/login
|
||||
* - /organization/{organizationId}/login
|
||||
* - /bank/{bankId}/login
|
||||
* - /admin/{adminId}/login
|
||||
*
|
||||
* Tests layered authentication, ownership verification, and intended URL handling.
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group authentication
|
||||
* @group direct-login
|
||||
*/
|
||||
class DirectLoginRoutesSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// USER DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test user direct login requires authentication
|
||||
*/
|
||||
public function test_user_direct_login_requires_authentication()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->get(route('user.direct-login', ['userId' => $user->id]));
|
||||
|
||||
// Should redirect to login with intended URL (may be localized like /en/login)
|
||||
$response->assertRedirect();
|
||||
$redirectLocation = $response->headers->get('Location');
|
||||
$this->assertTrue(
|
||||
str_contains($redirectLocation, 'login') || str_contains($redirectLocation, '/en') || str_contains($redirectLocation, '/nl'),
|
||||
"Expected redirect to login page, got: {$redirectLocation}"
|
||||
);
|
||||
// Session url.intended may be set by controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user direct login validates ownership
|
||||
*/
|
||||
public function test_user_direct_login_validates_ownership()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Try to access user2's profile
|
||||
$response = $this->get(route('user.direct-login', ['userId' => $user2->id]));
|
||||
|
||||
$this->assertLoggedOutToLogin($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user direct login redirects to intended URL
|
||||
*/
|
||||
public function test_user_direct_login_redirects_to_intended_url()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = route('main');
|
||||
$response = $this->get(route('user.direct-login', [
|
||||
'userId' => $user->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
$response->assertRedirect($intendedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user direct login returns 404 for nonexistent user
|
||||
*/
|
||||
public function test_user_direct_login_returns_404_for_nonexistent_user()
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'web');
|
||||
|
||||
$response = $this->get(route('user.direct-login', ['userId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ORGANIZATION DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test organization direct login requires user authentication first
|
||||
*/
|
||||
public function test_organization_direct_login_requires_user_authentication()
|
||||
{
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
|
||||
// Should store intended URL in session
|
||||
$this->assertNotNull(session('url.intended'));
|
||||
$this->assertStringContainsString("/organization/{$organization->id}/login", session('url.intended'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login validates ownership
|
||||
*/
|
||||
public function test_organization_direct_login_validates_ownership()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedOrg = Organization::factory()->create();
|
||||
$unownedOrg = Organization::factory()->create();
|
||||
|
||||
$user->organizations()->attach($ownedOrg->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned organization
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $unownedOrg->id]));
|
||||
|
||||
$this->assertLoggedOutToLogin($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login switches guard directly (passwordless)
|
||||
*/
|
||||
public function test_organization_direct_login_switches_guard_passwordless()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should switch to organization guard immediately without password
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
$this->assertEquals($organization->id, Auth::guard('organization')->id());
|
||||
|
||||
$response->assertRedirect(route('main'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login redirects to intended URL
|
||||
*/
|
||||
public function test_organization_direct_login_redirects_to_intended_url()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = '/some/deep/link';
|
||||
|
||||
$response = $this->get(route('organization.direct-login', [
|
||||
'organizationId' => $organization->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
$response->assertRedirect($intendedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login returns 404 for nonexistent organization
|
||||
*/
|
||||
public function test_organization_direct_login_returns_404_for_nonexistent_profile()
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BANK DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test bank direct login requires user authentication first
|
||||
*/
|
||||
public function test_bank_direct_login_requires_user_authentication()
|
||||
{
|
||||
$bank = Bank::factory()->create();
|
||||
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank direct login validates bank manager relationship
|
||||
*/
|
||||
public function test_bank_direct_login_validates_bank_manager_relationship()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$managedBank = Bank::factory()->create();
|
||||
$unmanagedBank = Bank::factory()->create();
|
||||
|
||||
$user->banksManaged()->attach($managedBank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unmanaged bank
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $unmanagedBank->id]));
|
||||
|
||||
$this->assertLoggedOutToLogin($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank direct login requires password
|
||||
*/
|
||||
public function test_bank_direct_login_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
||||
|
||||
// Should redirect to bank login form (not switch immediately)
|
||||
$this->assertStringStartsWith(route('bank.login'), $response->headers->get('Location'));
|
||||
|
||||
// Should NOT be authenticated on bank guard yet
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
|
||||
// Should set session intent
|
||||
$this->assertEquals('Bank', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($bank->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank direct login with intended URL stores it in session
|
||||
*/
|
||||
public function test_bank_direct_login_stores_intended_url_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = '/deep/link/to/transaction';
|
||||
|
||||
$this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
$this->assertEquals($intendedUrl, session('bank_login_intended_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank login fails with wrong password
|
||||
*/
|
||||
public function test_bank_direct_login_fails_with_wrong_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['password']);
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADMIN DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test admin direct login requires user authentication first
|
||||
*/
|
||||
public function test_admin_direct_login_requires_user_authentication()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin direct login validates admin user relationship
|
||||
*/
|
||||
public function test_admin_direct_login_validates_admin_user_relationship()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedAdmin = Admin::factory()->create();
|
||||
$unownedAdmin = Admin::factory()->create();
|
||||
|
||||
$user->admins()->attach($ownedAdmin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned admin
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $unownedAdmin->id]));
|
||||
|
||||
$this->assertLoggedOutToLogin($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin direct login requires password
|
||||
*/
|
||||
public function test_admin_direct_login_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('admin-password')]);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
// Should redirect to admin login form
|
||||
$this->assertStringStartsWith(route('admin.login'), $response->headers->get('Location'));
|
||||
|
||||
// Should NOT be authenticated on admin guard yet
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Should set session intent
|
||||
$this->assertEquals('Admin', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($admin->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin direct login fails for non-admin users
|
||||
*/
|
||||
public function test_admin_direct_login_fails_for_non_admin_users()
|
||||
{
|
||||
$regularUser = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
// Regular user not linked to admin
|
||||
$this->actingAs($regularUser, 'web');
|
||||
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
$this->assertLoggedOutToLogin($response);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION VARIABLE CLEANUP TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that session variables are cleared after successful authentication
|
||||
*/
|
||||
public function test_direct_login_session_variables_cleared_after_completion()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Set up direct login
|
||||
$this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => '/target/url',
|
||||
]));
|
||||
|
||||
$this->assertNotNull(session('intended_profile_switch_type'));
|
||||
$this->assertNotNull(session('intended_profile_switch_id'));
|
||||
$this->assertNotNull(session('bank_login_intended_url'));
|
||||
|
||||
// Complete authentication
|
||||
$this->post(route('bank.login.post'), [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// Session variables should be cleared
|
||||
$this->assertNull(session('intended_profile_switch_type'));
|
||||
$this->assertNull(session('intended_profile_switch_id'));
|
||||
$this->assertNull(session('bank_login_intended_url'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INTENDED URL VALIDATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that intended URL is properly encoded and decoded
|
||||
*/
|
||||
public function test_intended_url_properly_encoded_and_decoded()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = '/path/with spaces/and?query=params';
|
||||
$encodedUrl = urlencode($intendedUrl);
|
||||
|
||||
$response = $this->get(route('organization.direct-login', [
|
||||
'organizationId' => $organization->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
// URL should be properly handled
|
||||
$response->assertRedirect($intendedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that direct login handles missing intended URL gracefully
|
||||
*/
|
||||
public function test_direct_login_handles_missing_intended_url_gracefully()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', [
|
||||
'organizationId' => $organization->id,
|
||||
// No 'intended' parameter
|
||||
]));
|
||||
|
||||
// Should redirect to default (main page)
|
||||
$response->assertRedirect(route('main'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MULTI-LAYER AUTHENTICATION FLOW TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test complete flow: unauthenticated -> user login -> profile login
|
||||
*/
|
||||
public function test_complete_layered_authentication_flow()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('user-password'),
|
||||
]);
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Step 1: Try to access bank direct login while not authenticated
|
||||
$response = $this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => '/final/destination',
|
||||
]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
|
||||
// Step 2: Authenticate as user
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'user-password',
|
||||
]);
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Step 3: Now access bank direct login (should redirect to bank password form)
|
||||
$response = $this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => '/final/destination',
|
||||
]));
|
||||
|
||||
$this->assertStringStartsWith(route('bank.login'), $response->headers->get('Location'));
|
||||
|
||||
// Step 4: Enter bank password
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'bank-password',
|
||||
]);
|
||||
|
||||
// Should be authenticated on bank guard and redirected to final destination
|
||||
$this->assertTrue(Auth::guard('bank')->check());
|
||||
$response->assertRedirect('/final/destination');
|
||||
}
|
||||
|
||||
/**
|
||||
* A logged-in user opening a profile they do not own is logged out of every guard
|
||||
* and sent to the login, without switching to the profile
|
||||
*/
|
||||
private function assertLoggedOutToLogin($response): void
|
||||
{
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('/login', $response->headers->get('Location'));
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
$this->assertGuest($guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Multi-Guard Authentication Security Tests
|
||||
*
|
||||
* Tests the 4-guard authentication system:
|
||||
* - web: Individual user accounts
|
||||
* - organization: Non-profit organization profiles
|
||||
* - bank: Timebank operator profiles
|
||||
* - admin: Administrative profiles
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group authentication
|
||||
*/
|
||||
class MultiGuardAuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// BASIC GUARD AUTHENTICATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that a user can authenticate on the web guard
|
||||
*/
|
||||
public function test_user_can_authenticate_on_web_guard()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
$response = $this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// Assert authenticated on web guard
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$this->assertEquals($user->id, Auth::guard('web')->id());
|
||||
|
||||
// Assert not authenticated on other guards
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that authentication fails with invalid credentials
|
||||
*/
|
||||
public function test_cannot_authenticate_with_invalid_credentials()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('correct-password'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
// Assert not authenticated on any guard
|
||||
$this->assertFalse(Auth::guard('web')->check());
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a user cannot authenticate on wrong guard
|
||||
*/
|
||||
public function test_cannot_authenticate_user_on_organization_guard()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
// Attempt to manually authenticate user on organization guard (should not work)
|
||||
$result = Auth::guard('organization')->attempt([
|
||||
'email' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GUARD ISOLATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that web guard remains active when elevated guard is active
|
||||
*/
|
||||
public function test_web_guard_remains_active_with_elevated_guard()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create([
|
||||
'password' => Hash::make('org-password'),
|
||||
]);
|
||||
|
||||
// Link user to organization
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Authenticate on web guard
|
||||
Auth::guard('web')->login($user);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Now authenticate on organization guard
|
||||
Auth::guard('organization')->login($organization);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Both guards should be active
|
||||
$this->assertTrue(Auth::guard('web')->check(), 'Web guard should remain active');
|
||||
$this->assertTrue(Auth::guard('organization')->check(), 'Organization guard should be active');
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that only one elevated guard can be active at a time
|
||||
*/
|
||||
public function test_only_one_elevated_guard_active_at_time()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
$bank = Bank::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
// Link user to both profiles
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
// Authenticate on web guard first
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
// Authenticate on organization guard
|
||||
Auth::guard('organization')->login($organization);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
|
||||
// Now use SwitchGuardTrait to switch to bank (should logout organization)
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
$controller->switchGuard('bank', $bank);
|
||||
|
||||
// Only bank guard should be active among elevated guards
|
||||
$this->assertFalse(Auth::guard('organization')->check(), 'Organization guard should be logged out');
|
||||
$this->assertTrue(Auth::guard('bank')->check(), 'Bank guard should be active');
|
||||
$this->assertTrue(Auth::guard('web')->check(), 'Web guard should remain active');
|
||||
$this->assertEquals('bank', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test switching guard logs out other elevated guards
|
||||
*/
|
||||
public function test_switching_guard_logs_out_other_elevated_guards()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to all profiles
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
// Use SwitchGuardTrait
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
// Switch to organization
|
||||
$controller->switchGuard('organization', $organization);
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Switch to bank
|
||||
$controller->switchGuard('bank', $bank);
|
||||
$this->assertFalse(Auth::guard('organization')->check(), 'Organization should be logged out');
|
||||
$this->assertTrue(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Switch to admin
|
||||
$controller->switchGuard('admin', $admin);
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check(), 'Bank should be logged out');
|
||||
$this->assertTrue(Auth::guard('admin')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION STATE MANAGEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that active guard is stored in session
|
||||
*/
|
||||
public function test_active_guard_stored_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
$controller->switchGuard('organization', $organization);
|
||||
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that logging out non-web guards sets active guard to web
|
||||
*/
|
||||
public function test_logout_non_web_guards_sets_active_guard_to_web()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
$controller->switchGuard('organization', $organization);
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
|
||||
// Logout from non-web guards
|
||||
$controller->logoutNonWebGuards();
|
||||
|
||||
$this->assertEquals('web', session('active_guard'));
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTHENTICATION EDGE CASES
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that guest cannot access authenticated routes
|
||||
*/
|
||||
public function test_guest_cannot_access_authenticated_routes()
|
||||
{
|
||||
$response = $this->get(route('main'));
|
||||
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that authenticated user can access web guard routes
|
||||
*/
|
||||
public function test_authenticated_user_can_access_web_guard_routes()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('main'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authentication persists across requests
|
||||
*/
|
||||
public function test_authentication_persists_across_requests()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// First request - login
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Second request - should still be authenticated
|
||||
$response = $this->get(route('main'));
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that logging out clears authentication
|
||||
*/
|
||||
public function test_logging_out_clears_authentication()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$this->assertFalse(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CYCLOS LEGACY PASSWORD MIGRATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that legacy Cyclos passwords are migrated on successful login
|
||||
*/
|
||||
public function test_cyclos_password_migrated_on_successful_organization_login()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create([
|
||||
'cyclos_salt' => 'legacy_salt',
|
||||
'password' => strtolower(hash('sha256', 'legacy_salt' . 'old-password')),
|
||||
]);
|
||||
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Store intent in session
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Attempt login with old password
|
||||
$response = $this->post(route('organization.login.post'), [
|
||||
'password' => 'old-password',
|
||||
]);
|
||||
|
||||
// Refresh organization from database
|
||||
$organization->refresh();
|
||||
|
||||
// Assert password was rehashed and salt removed
|
||||
$this->assertNull($organization->cyclos_salt, 'Cyclos salt should be removed');
|
||||
$this->assertTrue(Hash::check('old-password', $organization->password), 'Password should be rehashed with Laravel Hash');
|
||||
|
||||
// Assert authentication succeeded
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Profile Switching Security Tests
|
||||
*
|
||||
* Tests the security of profile switching in the multi-guard system:
|
||||
* - Ownership verification
|
||||
* - Password requirements (organization: no password, bank/admin: requires password)
|
||||
* - Session state management
|
||||
* - Relationship validation
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group authentication
|
||||
* @group profile-switching
|
||||
*/
|
||||
class ProfileSwitchingSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// OWNERSHIP VERIFICATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that user can only switch to owned organization profiles
|
||||
*/
|
||||
public function test_user_can_only_switch_to_owned_organization()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedOrganization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$unownedOrganization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to owned organization only
|
||||
$user->organizations()->attach($ownedOrganization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Set intent for owned organization - should work
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $ownedOrganization->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('organization.login'));
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('profile-organization.login');
|
||||
|
||||
// Set intent for unowned organization - should fail
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $unownedOrganization->id,
|
||||
]);
|
||||
|
||||
// The form only resolves organizations the user belongs to
|
||||
$response = $this->get(route('organization.login'));
|
||||
$response->assertViewHas('profile', null);
|
||||
|
||||
// and the correct password still does not log in to the unowned organization
|
||||
$this->post(route('organization.login.post'), ['password' => 'password']);
|
||||
$this->assertGuest('organization');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that user cannot switch to unowned bank profile
|
||||
*/
|
||||
public function test_cannot_switch_to_unowned_bank()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedBank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$unownedBank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to owned bank only
|
||||
$user->banksManaged()->attach($ownedBank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned bank
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $unownedBank->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('bank.login'));
|
||||
|
||||
// The view will receive $profile = null from getTargetProfileByTypeAndId
|
||||
// This might cause an error or show an empty form
|
||||
$response->assertStatus(200); // The route exists but profile will be null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that user cannot switch to unowned admin profile
|
||||
*/
|
||||
public function test_cannot_switch_to_unowned_admin()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedAdmin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
$unownedAdmin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to owned admin only
|
||||
$user->admins()->attach($ownedAdmin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned admin
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Admin',
|
||||
'intended_profile_switch_id' => $unownedAdmin->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.login'));
|
||||
|
||||
$response->assertStatus(200); // Route exists but profile will be null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test profile switch validates relationship pivot tables
|
||||
*/
|
||||
public function test_profile_switch_validates_relationship_pivot_tables()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try organization without pivot entry
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $organization->id,
|
||||
]);
|
||||
$response = $this->get(route('organization.login'));
|
||||
// getTargetProfileByTypeAndId will return null (no relationship)
|
||||
|
||||
// Try bank without pivot entry
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
$response = $this->get(route('bank.login'));
|
||||
// getTargetProfileByTypeAndId will return null
|
||||
|
||||
// Try admin without pivot entry
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Admin',
|
||||
'intended_profile_switch_id' => $admin->id,
|
||||
]);
|
||||
$response = $this->get(route('admin.login'));
|
||||
// getTargetProfileByTypeAndId will return null
|
||||
|
||||
// Now add relationships and verify they work
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $organization->id,
|
||||
]);
|
||||
$response = $this->get(route('organization.login'));
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewHas('profile', $organization);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PASSWORD REQUIREMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that organization switch does not require password (via direct login)
|
||||
*/
|
||||
public function test_organization_switch_does_not_require_password_via_direct_login()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Direct login for organization should switch immediately
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should redirect to main page, not to password form
|
||||
$response->assertRedirect(route('main'));
|
||||
|
||||
// Should be authenticated on organization guard
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
$this->assertEquals($organization->id, Auth::guard('organization')->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that bank switch requires password
|
||||
*/
|
||||
public function test_bank_switch_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Direct login for bank should redirect to password form
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
||||
|
||||
$this->assertStringStartsWith(route('bank.login'), $response->headers->get('Location'));
|
||||
|
||||
// Should NOT be authenticated on bank guard yet
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
|
||||
// Verify session intent was set
|
||||
$this->assertEquals('Bank', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($bank->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that admin switch requires password
|
||||
*/
|
||||
public function test_admin_switch_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('admin-password')]);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Direct login for admin should redirect to password form
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
$this->assertStringStartsWith(route('admin.login'), $response->headers->get('Location'));
|
||||
|
||||
// Should NOT be authenticated on admin guard yet
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Verify session intent was set
|
||||
$this->assertEquals('Admin', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($admin->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that invalid password prevents bank profile switch
|
||||
*/
|
||||
public function test_invalid_password_prevents_bank_profile_switch()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['password']);
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that valid password allows bank profile switch
|
||||
*/
|
||||
public function test_valid_password_allows_bank_profile_switch()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'correct-password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('main'));
|
||||
$this->assertTrue(Auth::guard('bank')->check());
|
||||
$this->assertEquals($bank->id, Auth::guard('bank')->id());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION STATE MANAGEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that profile switch clears session variables after successful authentication
|
||||
*/
|
||||
public function test_profile_switch_clears_session_variables()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$this->assertNotNull(session('intended_profile_switch_type'));
|
||||
$this->assertNotNull(session('intended_profile_switch_id'));
|
||||
|
||||
$this->post(route('bank.login.post'), [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// Session variables should be cleared after successful login
|
||||
$this->assertNull(session('intended_profile_switch_type'));
|
||||
$this->assertNull(session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that active profile information is stored in session
|
||||
*/
|
||||
public function test_active_profile_stored_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Check session contains profile information
|
||||
$this->assertEquals(get_class($organization), session('activeProfileType'));
|
||||
$this->assertEquals($organization->id, session('activeProfileId'));
|
||||
$this->assertEquals($organization->name, session('activeProfileName'));
|
||||
$this->assertNotNull(session('last_activity'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// EDGE CASES
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that switching to nonexistent profile fails gracefully
|
||||
*/
|
||||
public function test_cannot_switch_to_nonexistent_profile()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access nonexistent organization
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that switching to soft-deleted profile fails
|
||||
*/
|
||||
public function test_cannot_switch_to_soft_deleted_profile()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Soft delete the organization
|
||||
$organization->delete();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access deleted organization
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that profile switch requires user to be authenticated
|
||||
*/
|
||||
public function test_profile_switch_requires_authentication()
|
||||
{
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Try to access organization without being authenticated
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should redirect to login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Session Security Tests
|
||||
*
|
||||
* Tests session security features:
|
||||
* - Session regeneration on login
|
||||
* - Session data protection
|
||||
* - Session cookie security
|
||||
* - Session clearing on logout
|
||||
*
|
||||
* @group security
|
||||
* @group high
|
||||
* @group authentication
|
||||
* @group session
|
||||
*/
|
||||
class SessionSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// SESSION REGENERATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that session is regenerated on user login
|
||||
*/
|
||||
public function test_session_regenerated_on_login()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Start session and get initial ID
|
||||
$this->startSession();
|
||||
$initialSessionId = Session::getId();
|
||||
|
||||
// Login
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// Session ID should have changed (regenerated)
|
||||
$newSessionId = Session::getId();
|
||||
$this->assertNotEquals($initialSessionId, $newSessionId, 'Session should be regenerated on login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that session is regenerated on profile switch
|
||||
*/
|
||||
public function test_session_regenerated_on_profile_switch()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$initialSessionId = Session::getId();
|
||||
|
||||
// Switch to organization profile
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Note: Session regeneration on profile switch may not be implemented
|
||||
// This test documents expected behavior
|
||||
// If this test fails, consider implementing session regeneration on profile switch for security
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION DATA PROTECTION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that sensitive data is not stored in session
|
||||
*/
|
||||
public function test_sensitive_data_not_stored_in_session()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('secret-password'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$sessionData = Session::all();
|
||||
|
||||
// Passwords should never be in session
|
||||
$sessionString = json_encode($sessionData);
|
||||
$this->assertStringNotContainsString('secret-password', $sessionString);
|
||||
$this->assertStringNotContainsString('password', strtolower($sessionString));
|
||||
|
||||
// Email might be OK in session depending on implementation
|
||||
// but password hash should definitely not be there
|
||||
foreach ($sessionData as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$this->assertStringNotContainsString('$2y$', $value, 'Password hash should not be in session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that active profile data is stored correctly in session
|
||||
*/
|
||||
public function test_active_profile_data_stored_correctly()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['name' => 'Test Org']);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Session should contain profile information but not sensitive data
|
||||
$this->assertEquals(get_class($organization), session('activeProfileType'));
|
||||
$this->assertEquals($organization->id, session('activeProfileId'));
|
||||
$this->assertEquals($organization->name, session('activeProfileName'));
|
||||
|
||||
// Session should have last activity time
|
||||
$this->assertNotNull(session('last_activity'));
|
||||
|
||||
// Session should NOT contain password
|
||||
$this->assertNull(session('activeProfilePassword'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION CLEARING TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that logging out clears authentication and session data
|
||||
*/
|
||||
public function test_logging_out_clears_authentication_and_session()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Login
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Logout
|
||||
$this->post(route('logout'));
|
||||
|
||||
// Should no longer be authenticated
|
||||
$this->assertFalse(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that profile switch session variables are cleared after login
|
||||
*/
|
||||
public function test_profile_switch_session_variables_cleared()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Set profile switch intent
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
'bank_login_intended_url' => '/test/url',
|
||||
]);
|
||||
|
||||
// Complete profile switch
|
||||
$this->post(route('bank.login.post'), [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// Session variables should be cleared
|
||||
$this->assertNull(session('intended_profile_switch_type'));
|
||||
$this->assertNull(session('intended_profile_switch_id'));
|
||||
$this->assertNull(session('bank_login_intended_url'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION PERSISTENCE TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that authentication persists across multiple requests
|
||||
*/
|
||||
public function test_authentication_persists_across_requests()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Make multiple requests
|
||||
$this->get(route('main'))->assertStatus(200);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Second request
|
||||
$this->get(route('main'))->assertStatus(200);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Third request
|
||||
$this->get(route('main'))->assertStatus(200);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// All requests should maintain authentication
|
||||
$this->assertEquals($user->id, Auth::guard('web')->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that multiple guards can be authenticated simultaneously
|
||||
*/
|
||||
public function test_multiple_guards_authenticated_simultaneously()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Authenticate on web guard
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
// Authenticate on organization guard
|
||||
Auth::guard('organization')->login($organization);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Both should be authenticated
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
|
||||
// Make a request - both should remain authenticated
|
||||
$this->get(route('main'));
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ACTIVE GUARD TRACKING TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that active guard is tracked in session
|
||||
*/
|
||||
public function test_active_guard_tracked_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Initially on web guard
|
||||
// Session might not have active_guard set initially
|
||||
|
||||
// Switch to organization
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that active guard changes when switching profiles
|
||||
*/
|
||||
public function test_active_guard_changes_when_switching_profiles()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Switch to organization
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
|
||||
// Switch to bank
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
$this->post(route('bank.login.post'), ['password' => 'password']);
|
||||
|
||||
$this->assertEquals('bank', session('active_guard'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION SECURITY EDGE CASES
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that unauthenticated users have no authentication data in session
|
||||
*/
|
||||
public function test_unauthenticated_users_have_no_auth_data_in_session()
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$sessionData = Session::all();
|
||||
|
||||
// Should not have authentication-related session keys
|
||||
$this->assertArrayNotHasKey('activeProfileType', $sessionData);
|
||||
$this->assertArrayNotHasKey('activeProfileId', $sessionData);
|
||||
$this->assertArrayNotHasKey('activeProfileName', $sessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that session data is properly isolated between users
|
||||
*/
|
||||
public function test_session_data_isolated_between_users()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Login as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
$sessionId1 = Session::getId();
|
||||
|
||||
// Logout, as the logout route does
|
||||
Auth::guard('web')->logout();
|
||||
Session::invalidate();
|
||||
|
||||
// Login as user2
|
||||
$this->actingAs($user2, 'web');
|
||||
$sessionId2 = Session::getId();
|
||||
|
||||
// Session IDs should be different
|
||||
$this->assertNotEquals($sessionId1, $sessionId2);
|
||||
|
||||
// Should be authenticated as user2, not user1
|
||||
$this->assertEquals($user2->id, Auth::guard('web')->id());
|
||||
$this->assertNotEquals($user1->id, Auth::guard('web')->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that session regeneration prevents session fixation attacks
|
||||
*/
|
||||
public function test_session_regeneration_prevents_fixation()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Attacker sets a known session ID
|
||||
$this->startSession();
|
||||
$attackerSessionId = Session::getId();
|
||||
|
||||
// Victim logs in with that session
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// Session ID should have changed, preventing attacker from hijacking
|
||||
$newSessionId = Session::getId();
|
||||
$this->assertNotEquals($attackerSessionId, $newSessionId,
|
||||
'Session ID should change on login to prevent fixation attacks');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
use Namu\WireChat\Models\Message;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Export Profile Data Authorization Tests
|
||||
*
|
||||
* Tests that users can only export their own profile data and cannot export
|
||||
* data from profiles they don't own/manage.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group export
|
||||
* @group critical
|
||||
*/
|
||||
class ExportProfileDataAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create transaction type for transaction exports
|
||||
\DB::table('transaction_types')->insertOrIgnore([
|
||||
'id' => 1,
|
||||
'name' => 'worked_hours',
|
||||
'label' => 'Worked Hours',
|
||||
'icon' => 'clock',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can export their own transactions
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_export_own_transactions()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTransactions', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot export another user's transactions via session manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_export_another_users_transactions()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
// Logged in as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to target user2
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTransactions', 'json');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can export own transactions
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_export_own_transactions()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTransactions', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot export another organization's transactions
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_export_another_organizations_transactions()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
|
||||
// Logged in as both web user and organization
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($org1, 'organization');
|
||||
|
||||
// Malicious: manipulate session to target org2
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTransactions', 'json');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can export own profile data
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_export_own_profile_data()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportProfileData', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot export another user's profile data
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_export_another_users_profile_data()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Logged in as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to target user2
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportProfileData', 'json');
|
||||
|
||||
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can export own messages
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_export_own_messages()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
// Create a conversation using sendMessageTo
|
||||
$user->sendMessageTo($recipient, 'Test message');
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportMessages', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot export another user's messages
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_export_another_users_messages()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
// Create messages for user2
|
||||
$user2->sendMessageTo($recipient, 'User2 private message');
|
||||
|
||||
// Logged in as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to target user2
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportMessages', 'json');
|
||||
|
||||
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can export own tags
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_export_own_tags()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create a tag and attach to user
|
||||
$tag = Tag::factory()->create();
|
||||
$user->tags()->attach($tag->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTags', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot export another user's tags
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_export_another_users_tags()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Create tags for user2
|
||||
$tag = Tag::factory()->create();
|
||||
$user2->tags()->attach($tag->id);
|
||||
|
||||
// Logged in as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to target user2
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTags', 'json');
|
||||
|
||||
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can export own contacts
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_export_own_contacts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$contact = User::factory()->create();
|
||||
|
||||
// Create a transaction to establish contact
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$contactAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $contact->id,
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $contactAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportContacts', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot export another user's contacts
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_export_another_users_contacts()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Logged in as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to target user2
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportContacts', 'json');
|
||||
|
||||
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can export own messages
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_export_own_messages()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
// Create a conversation from organization
|
||||
$organization->sendMessageTo($recipient, 'Org message');
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportMessages', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot export another organization's messages
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_export_another_organizations_messages()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($org1, 'organization');
|
||||
|
||||
// Malicious: manipulate session to target org2
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportMessages', 'json');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can export own tags
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_export_own_tags()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
// Create a tag and attach to organization
|
||||
$tag = Tag::factory()->create();
|
||||
$organization->tags()->attach($tag->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTags', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot export another organization's tags
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_export_another_organizations_tags()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($org1, 'organization');
|
||||
|
||||
// Malicious: manipulate session to target org2
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTags', 'json');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can export own contacts
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_export_own_contacts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportContacts', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot export another organization's contacts
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_export_another_organizations_contacts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($org1, 'organization');
|
||||
|
||||
// Malicious: manipulate session to target org2
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportContacts', 'json');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cross-guard attack: web user cannot export bank data
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function web_user_cannot_export_bank_data_cross_guard_attack()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
|
||||
// Logged in as user (web guard)
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Malicious: manipulate session to target bank profile
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportProfileData', 'json');
|
||||
|
||||
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can export own data when properly authenticated
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_export_own_data_when_properly_authenticated()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
|
||||
// Properly logged in as bank
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportProfileData', 'json');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot export any data
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_export_data()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Not authenticated
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\ExportProfileData::class)
|
||||
->call('exportTransactions', 'json');
|
||||
|
||||
// Should return 401 (not authenticated) rather than 403 (authenticated but unauthorized)
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Http\Livewire\Mailings\Manage as MailingsManage;
|
||||
use App\Http\Livewire\Profiles\Create as ProfilesCreate;
|
||||
use App\Http\Livewire\Tags\Create as TagsCreate;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Livewire Method-Level Authorization Tests
|
||||
*
|
||||
* Tests critical method-level authorization to prevent Livewire direct method invocation attacks.
|
||||
* Focuses on the two newly discovered critical vulnerabilities and authorization patterns.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group livewire
|
||||
* @group critical
|
||||
*/
|
||||
class LivewireMethodAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ===========================================
|
||||
// TAGS/CREATE.PHP - AUTHORIZATION TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function admin_can_call_tags_create_method()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function central_bank_can_call_tags_create_method()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 0]);
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function regular_bank_cannot_call_tags_create_method()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 1]);
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_call_tags_create_method()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function organization_cannot_call_tags_create_method()
|
||||
{
|
||||
$org = Organization::factory()->create();
|
||||
$this->actingAs($org, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// PROFILES/CREATE.PHP - CRITICAL VULNERABILITY FIX
|
||||
// This was a CRITICAL vulnerability - unauthorized profile creation
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function admin_can_access_profiles_create_component()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$component = Livewire::test(ProfilesCreate::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function central_bank_can_access_profiles_create_component()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 0]);
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$component = Livewire::test(ProfilesCreate::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_access_profiles_create_component()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$component = Livewire::test(ProfilesCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function organization_cannot_access_profiles_create_component()
|
||||
{
|
||||
$org = Organization::factory()->create();
|
||||
$this->actingAs($org, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org->id]);
|
||||
|
||||
$component = Livewire::test(ProfilesCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// MAILINGS/MANAGE.PHP - CRITICAL VULNERABILITY FIX
|
||||
// bulkDeleteMailings() method was unprotected
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function admin_can_access_mailings_manage_component()
|
||||
{
|
||||
$this->actAsLinkedAdmin();
|
||||
// Admins also need the mailings permission on their web user
|
||||
\Illuminate\Support\Facades\Gate::define('access mailings', fn () => true);
|
||||
|
||||
$component = Livewire::test(MailingsManage::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_without_mailings_permission_cannot_access_mailings_manage_component()
|
||||
{
|
||||
$this->actAsLinkedAdmin();
|
||||
\Illuminate\Support\Facades\Gate::define('access mailings', fn () => false);
|
||||
|
||||
$component = Livewire::test(MailingsManage::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
private function actAsLinkedAdmin(): Admin
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function central_bank_can_access_mailings_manage_component()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 0]);
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$component = Livewire::test(MailingsManage::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_access_mailings_manage_component()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$component = Livewire::test(MailingsManage::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function organization_cannot_access_mailings_manage_component()
|
||||
{
|
||||
$org = Organization::factory()->create();
|
||||
$this->actingAs($org, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $org->id]);
|
||||
|
||||
$component = Livewire::test(MailingsManage::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// CROSS-GUARD ATTACK PREVENTION TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function user_authenticated_on_wrong_guard_cannot_access_admin_components()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to fake being an admin by setting wrong session
|
||||
$admin = Admin::factory()->create();
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
// Should be blocked because guard doesn't match
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_cannot_access_other_admins_session()
|
||||
{
|
||||
$admin1 = Admin::factory()->create();
|
||||
$admin2 = Admin::factory()->create();
|
||||
|
||||
$this->actingAs($admin1, 'admin');
|
||||
// Try to fake session to access admin2's context
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin2->id]);
|
||||
|
||||
// Should be blocked by ProfileAuthorizationHelper IDOR prevention
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function unauthenticated_user_cannot_access_admin_components()
|
||||
{
|
||||
// No authentication at all - expect redirect or 403
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_with_no_session_cannot_access_admin_components()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
// Don't set session variables - this simulates a corrupted or missing session
|
||||
|
||||
// Set minimal session to prevent "No active profile" error
|
||||
// but make it point to a non-existent or wrong profile
|
||||
session([
|
||||
'activeProfileType' => User::class,
|
||||
'activeProfileId' => $user->id,
|
||||
'active_guard' => 'web',
|
||||
]);
|
||||
|
||||
// User (web guard) should not be able to access admin components
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// AUTHORIZATION CACHING TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function authorization_is_cached_within_same_request()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
// First check happens in mount()
|
||||
$component->assertStatus(200);
|
||||
|
||||
// Component can be used multiple times - caching works
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// BANK LEVEL VALIDATION TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function only_central_bank_level_zero_can_access_admin_functions()
|
||||
{
|
||||
// Test level 0 (central bank) - should work
|
||||
$centralBank = Bank::factory()->create(['level' => 0]);
|
||||
$this->actingAs($centralBank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $centralBank->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
$component->assertStatus(200);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_level_one_cannot_access_admin_functions()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 1]);
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_level_two_cannot_access_admin_functions()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 2]);
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$component = Livewire::test(TagsCreate::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Message Settings Authorization Tests
|
||||
*
|
||||
* Tests that users can only modify their own profile message settings
|
||||
* and cannot manipulate session to modify other profiles' settings.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group idor
|
||||
* @group critical
|
||||
*/
|
||||
class MessageSettingsAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test user can access their own message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_own_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSet('systemMessage', true); // Default value
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access another user's message settings via session manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_another_users_message_settings()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious attempt: manipulate session to access user2's settings
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user2->id]); // Different user!
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot update another user's message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_update_another_users_message_settings()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious attempt: manipulate session to update user2's settings
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user2->id]);
|
||||
|
||||
// The form refuses to mount for a profile the user does not own, so nothing can be saved
|
||||
Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class)->assertStatus(403);
|
||||
|
||||
$this->assertDatabaseMissing('message_settings', [
|
||||
'message_settingable_type' => User::class,
|
||||
'message_settingable_id' => $user2->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can access their own message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_access_own_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot access another organization's message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_access_another_organizations_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
|
||||
$org1->users()->attach($user->id);
|
||||
// User is NOT linked to org2
|
||||
|
||||
$this->actingAs($org1, 'organization');
|
||||
|
||||
// Malicious attempt: manipulate session to access org2's settings
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $org2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin can access their own message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_can_access_own_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
|
||||
session(['activeProfileType' => Admin::class]);
|
||||
session(['activeProfileId' => $admin->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can access their own message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_access_own_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
|
||||
session(['activeProfileType' => Bank::class]);
|
||||
session(['activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can update their own message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_update_own_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class)
|
||||
->set('systemMessage', false)
|
||||
->set('paymentReceived', true)
|
||||
->set('chatUnreadDelay', 24)
|
||||
->call('updateMessageSettings');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertDispatched('saved');
|
||||
|
||||
// Verify settings were saved
|
||||
$this->assertDatabaseHas('message_settings', [
|
||||
'message_settingable_type' => User::class,
|
||||
'message_settingable_id' => $user->id,
|
||||
'system_message' => false,
|
||||
'payment_received' => true,
|
||||
'chat_unread_delay' => 24,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cross-profile access: user logged in as user trying to access org settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_organization_message_settings_via_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Login as regular user (web guard)
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access organization settings via session manipulation
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
// Should fail because authenticated as User but trying to access Organization profile
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot access message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_access_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test message settings default values are created on first access
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function message_settings_default_values_created_on_first_access()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
// User has no message settings yet
|
||||
$this->assertDatabaseMissing('message_settings', [
|
||||
'message_settingable_type' => User::class,
|
||||
'message_settingable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\UpdateMessageSettingsForm::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Default settings should be created
|
||||
$this->assertDatabaseHas('message_settings', [
|
||||
'message_settingable_type' => User::class,
|
||||
'message_settingable_id' => $user->id,
|
||||
'system_message' => true,
|
||||
'payment_received' => true,
|
||||
'star_received' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Payment Multi-Auth Tests (Livewire Pay Component)
|
||||
*
|
||||
* Tests that the Livewire Pay component correctly validates account ownership
|
||||
* and prevents unauthorized payments across all authentication guards.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group multi-guard
|
||||
* @group payment
|
||||
* @group critical
|
||||
*/
|
||||
class PaymentMultiAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create transaction types (using insert to avoid mass assignment protection)
|
||||
\DB::table('transaction_types')->insertOrIgnore([
|
||||
['id' => 1, 'name' => 'worked_hours', 'label' => 'Worked Hours', 'icon' => 'clock'],
|
||||
['id' => 2, 'name' => 'gift', 'label' => 'Gift', 'icon' => 'gift'],
|
||||
['id' => 3, 'name' => 'donation', 'label' => 'Donation', 'icon' => 'hand-thumb-up'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can make payment from their own account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_make_payment_from_own_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Test payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot make payment from another user's account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_make_payment_from_another_users_account()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$user1Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user1->id]);
|
||||
|
||||
// Try to make payment from user2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $user2Account->id) // Unauthorized account!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Unauthorized payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
// Should redirect back with error
|
||||
$this->assertPaymentRefused($response);
|
||||
|
||||
// Transaction should NOT be created
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $user2Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can make payment from their own account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_make_payment_from_own_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actAsProfileManager($user, $organization, 'organization', "Organization\\{$organization->id}\\organization-manager");
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $orgAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 120)
|
||||
->set('description', 'Organization payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $orgAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot make payment from another organization's account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_make_payment_from_another_organizations_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$org1Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org1->id,
|
||||
]);
|
||||
|
||||
$org2Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as org1
|
||||
$this->actAsProfileManager($user, $org1, 'organization', "Organization\\{$org1->id}\\organization-manager");
|
||||
|
||||
// Try to make payment from org2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $org2Account->id) // Unauthorized!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 120)
|
||||
->set('description', 'Unauthorized org payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$this->assertPaymentRefused($response);
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $org2Account->id,
|
||||
'description' => 'Unauthorized org payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can make payment from their own account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_make_payment_from_own_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
'limit_min' => -10000,
|
||||
'limit_max' => 10000,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actAsProfileManager($user, $bank, 'bank', "Bank\\{$bank->id}\\bank-manager");
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $bankAccount->id)
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 200)
|
||||
->set('description', 'Bank payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $bankAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 200,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank cannot make payment from another bank's account
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_cannot_make_payment_from_another_banks_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank1 = Bank::factory()->create();
|
||||
$bank2 = Bank::factory()->create();
|
||||
$bank1->managers()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$bank1Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank1->id,
|
||||
]);
|
||||
|
||||
$bank2Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as bank1
|
||||
$this->actAsProfileManager($user, $bank1, 'bank', "Bank\\{$bank1->id}\\bank-manager");
|
||||
|
||||
// Try to make payment from bank2's account
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $bank2Account->id) // Unauthorized!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 200)
|
||||
->set('description', 'Unauthorized bank payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$this->assertPaymentRefused($response);
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $bank2Account->id,
|
||||
'description' => 'Unauthorized bank payment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test session manipulation attack prevention
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function session_manipulation_attack_is_prevented()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$user1Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to impersonate user2
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user2->id]); // Attacker sets this to user2!
|
||||
|
||||
// The pay form refuses to mount for a profile the logged-in user does not own,
|
||||
// so the payment can never be submitted (doTransfer() also re-checks ownership)
|
||||
Livewire::test(\App\Http\Livewire\Pay::class)->assertStatus(403);
|
||||
|
||||
// Transaction should NOT be created
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $user2Account->id,
|
||||
'description' => 'Session manipulation attack',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cross-guard attack prevention (user trying to use org account)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function cross_guard_attack_is_prevented()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Login as user (web guard)
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
// Try to make payment from organization's account while logged in as user
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $orgAccount->id) // Org account, but user guard!
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 120)
|
||||
->set('description', 'Cross-guard attack')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$this->assertPaymentRefused($response);
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $orgAccount->id,
|
||||
'description' => 'Cross-guard attack',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment to same account is prevented
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function payment_to_same_account_is_prevented()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', $userAccount->id) // Same account!
|
||||
->set('amount', 60)
|
||||
->set('description', 'Self payment')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$this->assertPaymentRefused($response);
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $userAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment to non-existent account is prevented
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function payment_to_nonexistent_account_is_prevented()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $userAccount->id)
|
||||
->set('toAccountId', 99999) // Non-existent account
|
||||
->set('amount', 60)
|
||||
->set('description', 'Payment to nowhere')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$this->assertPaymentRefused($response);
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'to_account_id' => 99999,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot make payments
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_make_payments()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
// Not authenticated: the pay page sends guests to the login, so the component is never reached
|
||||
$this->get(route('pay'))->assertRedirect();
|
||||
$this->assertGuest('web');
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $userAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin profile without account cannot make payments
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_without_account_cannot_make_payments()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
// Admin has no account
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class]);
|
||||
session(['activeProfileId' => $admin->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', null) // No account
|
||||
->set('toAccountId', $recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Admin payment without account')
|
||||
->set('transactionTypeSelected', TransactionType::find(1)->toArray())
|
||||
->call('doTransfer');
|
||||
|
||||
$this->assertPaymentRefused($response);
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the user behind an elevated profile, with the role that may pay for it
|
||||
*/
|
||||
private function actAsProfileManager(User $user, $profile, string $guard, string $role): void
|
||||
{
|
||||
\Spatie\Permission\Models\Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
|
||||
$user->assignRole($role);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($profile, $guard);
|
||||
session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused payment is logged, reported and shown to the user as an error notification
|
||||
*/
|
||||
private function assertPaymentRefused($response): void
|
||||
{
|
||||
$response->assertDispatched('wireui:notification', function ($event, $params) {
|
||||
return ($params[0]['options']['icon'] ?? $params['options']['icon'] ?? null) === 'error';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The Pay component must decide on the server which transaction types a payment
|
||||
* may use. Livewire public properties can be rewritten by the browser, so the
|
||||
* allowed list and the selected type must not be trusted.
|
||||
*
|
||||
* @group security
|
||||
* @group payment
|
||||
*/
|
||||
class PaymentTypeTamperingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $payer;
|
||||
private Account $payerAccount;
|
||||
private Account $recipientAccount;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
foreach ([1 => 'Work', 2 => 'Gift', 3 => 'Donation', 4 => 'Currency creation', 6 => 'Migration'] as $id => $name) {
|
||||
\DB::table('transaction_types')->insertOrIgnore(['id' => $id, 'name' => $name, 'label' => $name, 'icon' => 'clock']);
|
||||
}
|
||||
|
||||
$this->payer = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->payerAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $this->payer->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
$this->recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
'limit_min' => -1000,
|
||||
'limit_max' => 1000,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->payer, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $this->payer->id, 'active_guard' => 'web']);
|
||||
}
|
||||
|
||||
private function pay(int $typeId, ?array $allowedTypes)
|
||||
{
|
||||
return Livewire::test(\App\Http\Livewire\Pay::class)
|
||||
->set('fromAccountId', $this->payerAccount->id)
|
||||
->set('toAccountId', $this->recipientAccount->id)
|
||||
->set('amount', 60)
|
||||
->set('description', 'Type tampering')
|
||||
->set('typeOptionsProtected', $allowedTypes)
|
||||
->set('transactionTypeSelected', TransactionType::find($typeId)->toArray())
|
||||
->call('doTransfer');
|
||||
}
|
||||
|
||||
private function assertNoTransaction(int $typeId): void
|
||||
{
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $this->payerAccount->id,
|
||||
'transaction_type_id' => $typeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allowed_type_is_paid(): void
|
||||
{
|
||||
$this->pay(1, [1, 2, 3]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $this->payerAccount->id,
|
||||
'to_account_id' => $this->recipientAccount->id,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function rewritten_allowed_types_do_not_unlock_currency_creation(): void
|
||||
{
|
||||
$this->pay(4, [1, 2, 3, 4]);
|
||||
|
||||
$this->assertNoTransaction(4);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function migration_type_is_refused_between_different_holders(): void
|
||||
{
|
||||
$this->pay(6, [1, 2, 3]);
|
||||
|
||||
$this->assertNoTransaction(6);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allowed_types_are_decided_on_the_server_not_by_the_client_list(): void
|
||||
{
|
||||
// An emptied client list neither crashes nor blocks a type the server allows
|
||||
$this->pay(1, null);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $this->payerAccount->id,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Posts Management Authorization Tests
|
||||
*
|
||||
* Tests that only admins and central banks can access post management,
|
||||
* and prevents IDOR/cross-guard attacks.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group admin
|
||||
* @group critical
|
||||
*/
|
||||
class PostsManageAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test admin can access posts management
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_can_access_posts_management()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test central bank (level 0) can access posts management
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function central_bank_can_access_posts_management()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 0]);
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test regular bank (level 1) CANNOT access posts management
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function regular_bank_cannot_access_posts_management()
|
||||
{
|
||||
$bank = Bank::factory()->create(['level' => 1]);
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user CANNOT access posts management
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_posts_management()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization CANNOT access posts management
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_access_posts_management()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web user CANNOT access posts via cross-guard attack (targeting admin profile)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function web_user_cannot_access_posts_via_cross_guard_admin_attack()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id); // User is linked to admin
|
||||
|
||||
// User authenticated on 'web' guard
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Malicious: manipulate session to target admin profile
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
// Should be blocked by ProfileAuthorizationHelper (cross-guard validation)
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web user CANNOT access posts via cross-guard attack (targeting bank profile)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function web_user_cannot_access_posts_via_cross_guard_bank_attack()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['level' => 0]);
|
||||
$bank->managers()->attach($user->id); // User is manager of bank
|
||||
|
||||
// User authenticated on 'web' guard
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Malicious: manipulate session to target bank profile
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
// Should be blocked by ProfileAuthorizationHelper (cross-guard validation)
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user CANNOT access posts management
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_access_posts_management()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
// Not authenticated
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
// Should return 401 (not authenticated)
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin CANNOT access posts when session has no active profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_cannot_access_posts_without_active_profile()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
// NO session activeProfileType/activeProfileId set
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin CANNOT access posts when session has invalid profile ID
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_cannot_access_posts_with_invalid_profile_id()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => 99999]); // Non-existent ID
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin CANNOT access posts management for different admin profile (IDOR)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_cannot_access_posts_as_different_admin()
|
||||
{
|
||||
$admin1 = Admin::factory()->create();
|
||||
$admin2 = Admin::factory()->create();
|
||||
|
||||
// Authenticated as admin1
|
||||
$this->actingAs($admin1, 'admin');
|
||||
|
||||
// Malicious: manipulate session to target admin2 profile
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin2->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\Posts\Manage::class);
|
||||
|
||||
// Should be blocked by ProfileAuthorizationHelper (different admin)
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ProfileAuthorizationHelper Multi-Guard Tests
|
||||
*
|
||||
* Tests that ProfileAuthorizationHelper correctly validates profile access
|
||||
* across all authentication guards (web, admin, organization, bank).
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group multi-guard
|
||||
* @group critical
|
||||
*/
|
||||
class ProfileAuthorizationHelperTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test user can access their own user profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_own_user_profile()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($user);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access another user's profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_another_users_profile()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($user2);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin can access their own admin profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_can_access_own_admin_profile()
|
||||
{
|
||||
// Create admin and link to user
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($admin);
|
||||
|
||||
$this->assertTrue($result, 'Admin should be able to access their own admin profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can access their own organization profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_access_own_organization_profile()
|
||||
{
|
||||
// Create organization and link to user
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($organization);
|
||||
|
||||
$this->assertTrue($result, 'Organization should be able to access their own profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can access their own bank profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_access_own_bank_profile()
|
||||
{
|
||||
// Create bank and link to user
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($bank);
|
||||
|
||||
$this->assertTrue($result, 'Bank should be able to access their own bank profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can access organization they are member of (for profile switching)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_organization_they_are_member_of()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Use userOwnsProfile() for cross-guard ownership checks (profile switching scenario)
|
||||
$result = ProfileAuthorizationHelper::userOwnsProfile($organization);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access organization they are not member of
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_organization_they_are_not_member_of()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
// User is NOT attached to organization
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($organization);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can access bank they manage (for profile switching)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_bank_they_manage()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Use userOwnsProfile() for cross-guard ownership checks (profile switching scenario)
|
||||
$result = ProfileAuthorizationHelper::userOwnsProfile($bank);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access bank they don't manage
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_bank_they_dont_manage()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
// User is NOT attached to bank
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($bank);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can access admin profile they are linked to (for profile switching)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_admin_profile_they_are_linked_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Use userOwnsProfile() for cross-guard ownership checks (profile switching scenario)
|
||||
$result = ProfileAuthorizationHelper::userOwnsProfile($admin);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access admin profile they are not linked to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_admin_profile_they_are_not_linked_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
// User is NOT attached to admin
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($admin);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin cannot directly switch to organization (must go through web user)
|
||||
*
|
||||
* In the application, profile switching flow is: User → Admin → back to User → Organization
|
||||
* Direct Admin → Organization switching is not supported.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_can_access_organization_via_linked_user()
|
||||
{
|
||||
// Create user linked to both admin and organization
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$admin->users()->attach($user->id);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
|
||||
// userOwnsProfile() only checks web guard, so this should return false
|
||||
// Profile switching requires being on web guard first
|
||||
$result = ProfileAuthorizationHelper::userOwnsProfile($organization);
|
||||
|
||||
$this->assertFalse($result, 'Admin cannot directly switch to organization without going through web user first');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot directly switch to bank (must go through web user)
|
||||
*
|
||||
* In the application, profile switching flow is: User → Organization → back to User → Bank
|
||||
* Direct Organization → Bank switching is not supported.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_access_bank_via_linked_user()
|
||||
{
|
||||
// Create user linked to both organization and bank
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
|
||||
$organization->users()->attach($user->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
|
||||
// userOwnsProfile() only checks web guard, so this should return false
|
||||
// Profile switching requires being on web guard first
|
||||
$result = ProfileAuthorizationHelper::userOwnsProfile($bank);
|
||||
|
||||
$this->assertFalse($result, 'Organization cannot directly switch to bank without going through web user first');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin cannot access unrelated organization
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_cannot_access_unrelated_organization()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$admin->users()->attach($user->id);
|
||||
// User is NOT linked to organization
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($organization);
|
||||
|
||||
$this->assertFalse($result, 'Admin should NOT access unrelated organization');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authorize method throws 403 for unauthorized access
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function authorize_method_throws_403_for_unauthorized_access()
|
||||
{
|
||||
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
|
||||
$this->expectExceptionMessage('Unauthorized');
|
||||
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
ProfileAuthorizationHelper::authorize($user2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated access is denied
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_access_is_denied()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($user);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authorize method throws 401 for unauthenticated access
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function authorize_method_throws_401_for_unauthenticated()
|
||||
{
|
||||
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
|
||||
$this->expectExceptionMessage('Authentication required');
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
ProfileAuthorizationHelper::authorize($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin cannot access another admin profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_cannot_access_another_admin_profile()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$admin1 = Admin::factory()->create();
|
||||
$admin2 = Admin::factory()->create();
|
||||
|
||||
$admin1->users()->attach($user1->id);
|
||||
|
||||
$this->actingAs($admin1, 'admin');
|
||||
|
||||
$result = ProfileAuthorizationHelper::can($admin2);
|
||||
|
||||
$this->assertFalse($result, 'Admin should NOT access another admin profile');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Namu\WireChat\Helpers\MorphClassResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* A tab left open across a profile switch keeps the chat channels it subscribed to. The next chat
|
||||
* event must still find its handler, or Livewire answers "Handler for event ... does not exist".
|
||||
*
|
||||
* @group security
|
||||
*/
|
||||
class ProfileChatListenersTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Organization $organization;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->organization = Organization::factory()->create();
|
||||
$this->organization->users()->attach($this->user->id);
|
||||
}
|
||||
|
||||
private function channel($profile): string
|
||||
{
|
||||
$type = MorphClassResolver::encode($profile->getMorphClass());
|
||||
|
||||
return "echo-private:participant.{$type}.{$profile->getKey()},.Namu\\WireChat\\Events\\NotifyParticipant";
|
||||
}
|
||||
|
||||
private function activateUser(): void
|
||||
{
|
||||
$this->actingAs($this->user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $this->user->id, 'active_guard' => 'web']);
|
||||
}
|
||||
|
||||
private function activateOrganization(): void
|
||||
{
|
||||
$this->actingAs($this->user, 'web');
|
||||
$this->actingAs($this->organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class, 'activeProfileId' => $this->organization->id, 'active_guard' => 'organization']);
|
||||
}
|
||||
|
||||
private function event(): array
|
||||
{
|
||||
return ['message' => ['conversation_id' => 999]];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function switch_profile_indicator_handles_an_event_for_the_profile_that_became_active(): void
|
||||
{
|
||||
$this->activateUser();
|
||||
// Rendered as the user: listens on the organization's channel
|
||||
$component = Livewire::test(\App\Http\Livewire\SwitchProfileUnreadIndicator::class);
|
||||
|
||||
$this->activateOrganization();
|
||||
$component->dispatch($this->channel($this->organization), $this->event())->assertOk();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function unread_indicator_handles_an_event_for_the_profile_that_was_active(): void
|
||||
{
|
||||
$this->activateUser();
|
||||
// Rendered as the user: listens on the user's channel
|
||||
$component = Livewire::test(\App\Http\Livewire\UnreadIndicator::class);
|
||||
|
||||
$this->activateOrganization();
|
||||
$component->dispatch($this->channel($this->user), $this->event())->assertOk();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function listeners_are_the_same_whichever_profile_is_active(): void
|
||||
{
|
||||
$this->activateUser();
|
||||
$asUser = array_keys((new \App\Http\Livewire\UnreadIndicator)->getListeners());
|
||||
|
||||
$this->activateOrganization();
|
||||
$asOrganization = array_keys((new \App\Http\Livewire\UnreadIndicator)->getListeners());
|
||||
|
||||
$this->assertEqualsCanonicalizing($asUser, $asOrganization);
|
||||
$this->assertContains($this->channel($this->user), $asUser);
|
||||
$this->assertContains($this->channel($this->organization), $asUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Admin;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Profile Deletion Authorization Tests
|
||||
*
|
||||
* Tests that users can only delete their own profiles and cannot delete
|
||||
* profiles they don't own or have access to.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group critical
|
||||
*/
|
||||
class ProfileDeletionAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test user cannot delete another user's profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_delete_another_users_profile()
|
||||
{
|
||||
// Arrange: Create two users
|
||||
$user1 = User::factory()->create(['password' => bcrypt('password123')]);
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Act: Login as user1 and try to delete user2's profile
|
||||
$this->actingAs($user1);
|
||||
|
||||
// Create session as if user1 is trying to delete user2
|
||||
session(['activeProfileType' => 'App\\Models\\User']);
|
||||
session(['activeProfileId' => $user2->id]); // Malicious attempt
|
||||
session(['active_guard' => 'web']);
|
||||
|
||||
// Attempt deletion
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
|
||||
->set('password', 'password123')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
|
||||
|
||||
// Assert: The operation should fail or delete user1 (not user2)
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user2->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot delete organization they don't have access to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_delete_organization_they_dont_own()
|
||||
{
|
||||
// Arrange: Create user and two organizations
|
||||
$user = User::factory()->create(['password' => bcrypt('password123')]);
|
||||
$org1 = Organization::factory()->create(['password' => bcrypt('orgpass123')]);
|
||||
$org2 = Organization::factory()->create();
|
||||
|
||||
// Link user to org1 only
|
||||
$user->organizations()->attach($org1->id);
|
||||
|
||||
// Act: Login as user and try to delete org2
|
||||
$this->actingAs($user);
|
||||
|
||||
session(['activeProfileType' => 'App\\Models\\Organization']);
|
||||
session(['activeProfileId' => $org2->id]); // Malicious attempt
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Attempt deletion
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
|
||||
->set('password', 'password123')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard('organization'));
|
||||
|
||||
// Assert: org2 should NOT be deleted
|
||||
$this->assertDatabaseHas('organizations', [
|
||||
'id' => $org2->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test central bank (level 0) cannot be deleted
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function central_bank_cannot_be_deleted()
|
||||
{
|
||||
// Arrange: Create central bank
|
||||
$centralBank = Bank::factory()->create([
|
||||
'level' => 0,
|
||||
'password' => bcrypt('bankpass123')
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->banksManaged()->attach($centralBank->id);
|
||||
|
||||
// Act: Login and try to delete central bank
|
||||
$this->actingAs($user);
|
||||
auth()->guard('bank')->login($centralBank);
|
||||
|
||||
session(['activeProfileType' => 'App\\Models\\Bank']);
|
||||
session(['activeProfileId' => $centralBank->id]);
|
||||
session(['active_guard' => 'bank']);
|
||||
|
||||
// Attempt deletion
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
|
||||
->set('password', 'bankpass123')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard('bank'));
|
||||
|
||||
// Assert: Deletion should fail with validation error
|
||||
$response->assertHasErrors(['password']);
|
||||
|
||||
// Assert: Central bank still exists
|
||||
$this->assertDatabaseHas('banks', [
|
||||
'id' => $centralBank->id,
|
||||
'level' => 0,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test final admin cannot be deleted
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function final_admin_cannot_be_deleted()
|
||||
{
|
||||
// Arrange: Create single admin
|
||||
$admin = Admin::factory()->create(['password' => bcrypt('adminpass123')]);
|
||||
$user = User::factory()->create();
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
// Act: Login and try to delete the only admin
|
||||
$this->actingAs($user);
|
||||
auth()->guard('admin')->login($admin);
|
||||
|
||||
session(['activeProfileType' => 'App\\Models\\Admin']);
|
||||
session(['activeProfileId' => $admin->id]);
|
||||
session(['active_guard' => 'admin']);
|
||||
|
||||
// Attempt deletion
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
|
||||
->set('password', 'adminpass123')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard('admin'));
|
||||
|
||||
// Assert: Deletion should fail with validation error
|
||||
$response->assertHasErrors(['password']);
|
||||
|
||||
// Assert: Admin still exists
|
||||
$this->assertDatabaseHas('admins', [
|
||||
'id' => $admin->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can delete their own profile with correct password
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_delete_own_profile_with_correct_password()
|
||||
{
|
||||
// Arrange: Create user
|
||||
$user = User::factory()->create(['password' => bcrypt('password123')]);
|
||||
|
||||
// Act: Login and delete own profile
|
||||
$this->actingAs($user);
|
||||
|
||||
session(['activeProfileType' => 'App\\Models\\User']);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
session(['active_guard' => 'web']);
|
||||
|
||||
// Attempt deletion with correct password
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
|
||||
->set('password', 'password123')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
|
||||
|
||||
// Assert: User is soft-deleted
|
||||
$this->assertSoftDeleted('users', [
|
||||
'id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot delete profile with wrong password
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_delete_profile_with_wrong_password()
|
||||
{
|
||||
// Arrange: Create user
|
||||
$user = User::factory()->create(['password' => bcrypt('password123')]);
|
||||
|
||||
// Act: Login and try to delete with wrong password
|
||||
$this->actingAs($user);
|
||||
|
||||
session(['activeProfileType' => 'App\\Models\\User']);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
session(['active_guard' => 'web']);
|
||||
|
||||
// Attempt deletion with wrong password
|
||||
$response = Livewire::test(\App\Http\Livewire\Profile\DeleteUserForm::class)
|
||||
->set('password', 'wrongpassword')
|
||||
->call('deleteUser', request(), app(\Laravel\Jetstream\Contracts\DeletesUsers::class), auth()->guard());
|
||||
|
||||
// Assert: Deletion should fail with validation error
|
||||
$response->assertHasErrors(['password']);
|
||||
|
||||
// Assert: User still exists
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot access delete form
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_access_delete_form()
|
||||
{
|
||||
// The delete form lives on the profile settings page, which sends guests to the login
|
||||
$response = $this->get(route('profile.settings'));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('/login', $response->headers->get('Location'));
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Transaction View Authorization Tests
|
||||
*
|
||||
* Tests that users can only view transactions involving their own accounts
|
||||
* and cannot view other users' transactions across all authentication guards.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group multi-guard
|
||||
* @group transaction-view
|
||||
* @group critical
|
||||
*/
|
||||
class TransactionViewAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create transaction types (using insert to avoid mass assignment protection)
|
||||
\DB::table('transaction_types')->insertOrIgnore([
|
||||
'id' => 1,
|
||||
'name' => 'worked_hours',
|
||||
'label' => 'Worked Hours',
|
||||
'icon' => 'clock',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can view transaction they are involved in (sender)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_view_transaction_as_sender()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can view transaction they are involved in (recipient)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_view_transaction_as_recipient()
|
||||
{
|
||||
$sender = User::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $userAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot view transaction they are not involved in
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_view_transaction_they_are_not_involved_in()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$sender = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
// User is NOT involved in this transaction
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can view transaction they are involved in
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_view_transaction_they_are_involved_in()
|
||||
{
|
||||
$orgUser = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($orgUser->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $orgAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
|
||||
$this->actingAs($orgUser, 'web');
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot view transaction of another organization
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_view_another_organizations_transaction()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$org1->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$org2Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $org2Account->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
|
||||
// Logged in as org1, trying to view org2's transaction
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($org1, 'organization');
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $org1->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can view transaction they are involved in
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_view_transaction_they_are_involved_in()
|
||||
{
|
||||
$bankUser = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($bankUser->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $bankAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 200,
|
||||
]);
|
||||
|
||||
$this->actingAs($bankUser, 'web');
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class]);
|
||||
session(['activeProfileId' => $bank->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test session manipulation to view unauthorized transaction is blocked
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function session_manipulation_to_view_transaction_is_blocked()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $user2Account->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
// Logged in as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Malicious: manipulate session to impersonate user2
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user2->id]); // Attacker sets this!
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
// Should be blocked by statement() query which checks session against database
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cross-guard attack to view transaction is blocked
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function cross_guard_attack_to_view_transaction_is_blocked()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $orgAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 120,
|
||||
]);
|
||||
|
||||
// Logged in as user (web guard)
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Malicious: manipulate session to view org's transaction
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
// Should be blocked because auth guard doesn't match session
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot view transactions
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_view_transactions()
|
||||
{
|
||||
$sender = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
|
||||
// Not authenticated
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $transaction->id]));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user can access transactions list page
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_transactions_list_page()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = $this->get(route('transactions'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can access transactions list page
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_access_transactions_list_page()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = $this->get(route('transactions'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can access transactions list page
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_access_transactions_list_page()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class]);
|
||||
session(['activeProfileId' => $bank->id]);
|
||||
|
||||
$response = $this->get(route('transactions'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test non-existent transaction returns 403
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function non_existent_transaction_returns_404()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TransactionsTable Livewire component loads for user
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function transactions_table_livewire_component_loads_for_user()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\TransactionsTable::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TransactionsTable Livewire component loads for organization
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function transactions_table_livewire_component_loads_for_organization()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
session(['activeProfileType' => Organization::class]);
|
||||
session(['activeProfileId' => $organization->id]);
|
||||
|
||||
$response = Livewire::test(\App\Http\Livewire\TransactionsTable::class);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TransactionsTable filters transactions by active profile
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function transactions_table_filters_by_active_profile()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$user1Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$user2Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
// Transaction involving user1
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $user1Account->id,
|
||||
'to_account_id' => $user2Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'User1 transaction',
|
||||
]);
|
||||
|
||||
$user3Account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
// Transaction NOT involving user1
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $user2Account->id,
|
||||
'to_account_id' => $user3Account->id,
|
||||
'amount' => 30,
|
||||
'description' => 'User2 only transaction',
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
session(['activeProfileType' => User::class]);
|
||||
session(['activeProfileId' => $user1->id]);
|
||||
|
||||
// The account selector sets the account to list
|
||||
$response = Livewire::test(\App\Http\Livewire\TransactionsTable::class)
|
||||
->set('fromAccountId', $user1Account->id);
|
||||
|
||||
// Should see user1's transaction
|
||||
$response->assertSee('User1 transaction');
|
||||
|
||||
// Should NOT see user2's private transaction
|
||||
$response->assertDontSee('User2 only transaction');
|
||||
|
||||
// Selecting another profile's account lists nothing
|
||||
$response->set('fromAccountId', $user2Account->id)
|
||||
->assertDontSee('User2 only transaction');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* WireChat Multi-Auth Tests
|
||||
*
|
||||
* Tests that WireChat components work correctly with multi-guard authentication
|
||||
* for User, Organization, Bank, and Admin profiles.
|
||||
*
|
||||
* @group security
|
||||
* @group authorization
|
||||
* @group multi-guard
|
||||
* @group wirechat
|
||||
*/
|
||||
class WireChatMultiAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test user can access conversation they belong to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_can_access_conversation_they_belong_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Create conversation using sendMessageTo (same logic as Pay.php line 417)
|
||||
$message = $user->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access conversation they don't belong to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_conversation_they_dont_belong_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$anotherUser = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Set active profile in session (required by getActiveProfile())
|
||||
session([
|
||||
'activeProfileType' => get_class($user),
|
||||
'activeProfileId' => $user->id,
|
||||
'active_guard' => 'web',
|
||||
]);
|
||||
|
||||
// Create conversation between otherUser and anotherUser (not involving $user)
|
||||
$message = $otherUser->sendMessageTo($anotherUser, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization can access conversation they belong to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_access_conversation_they_belong_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
|
||||
// Create conversation with organization as sender
|
||||
$message = $organization->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin can access conversation they belong to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_can_access_conversation_they_belong_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
|
||||
// Create conversation with admin as sender
|
||||
$message = $admin->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can access conversation they belong to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_access_conversation_they_belong_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
|
||||
// Create conversation with bank as sender
|
||||
$message = $bank->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization cannot access conversation they don't belong to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_cannot_access_conversation_they_dont_belong_to()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$org1->users()->attach($user->id);
|
||||
$org2->users()->attach($recipient->id);
|
||||
|
||||
$this->actingAs($org1, 'organization');
|
||||
|
||||
// Set active profile in session (required by getActiveProfile())
|
||||
session([
|
||||
'activeProfileType' => get_class($org1),
|
||||
'activeProfileId' => $org1->id,
|
||||
'active_guard' => 'organization',
|
||||
]);
|
||||
|
||||
// Create conversation with org2 (not org1)
|
||||
$message = $org2->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unauthenticated user cannot access conversations
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function unauthenticated_user_cannot_access_conversations()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Create conversation between two users
|
||||
$message = $user1->sendMessageTo($user2, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
// No authentication - accessing as guest
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test multi-participant conversation access (User and Organization)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function multi_participant_conversation_allows_both_participants()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
// Create conversation between user and organization
|
||||
$message = $user->sendMessageTo($organization, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
// Test 1: User can access
|
||||
$this->actingAs($user, 'web');
|
||||
$response1 = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
$response1->assertStatus(200);
|
||||
|
||||
// Test 2: Organization can access
|
||||
$this->actingAs($organization, 'organization');
|
||||
$response2 = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
$response2->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test disappearing messages can be enabled by organization
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function organization_can_enable_disappearing_messages()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
|
||||
// Create conversation with organization as sender
|
||||
$message = $organization->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
// Check that component loaded successfully
|
||||
$response->assertStatus(200);
|
||||
$response->assertSet('conversationId', $conversation->id);
|
||||
$response->assertSet('platformEnabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin can access disappearing message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function admin_can_access_disappearing_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
$admin->users()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin, 'admin');
|
||||
|
||||
// Create conversation with admin as sender
|
||||
$message = $admin->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewHas('conversation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank can access disappearing message settings
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bank_can_access_disappearing_message_settings()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bank->managers()->attach($user->id);
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
|
||||
// Create conversation with bank as sender
|
||||
$message = $bank->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
$response = Livewire::test(
|
||||
\App\Http\Livewire\WireChat\DisappearingMessagesSettings::class,
|
||||
['conversationId' => $conversation->id]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSet('conversation', function ($conversation) {
|
||||
return $conversation instanceof Conversation;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test conversation access via route middleware (belongsToConversation)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function route_middleware_blocks_unauthorized_conversation_access()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$anotherUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Set active profile in session (required by getActiveProfile())
|
||||
session([
|
||||
'activeProfileType' => get_class($user),
|
||||
'activeProfileId' => $user->id,
|
||||
'active_guard' => 'web',
|
||||
]);
|
||||
|
||||
// Create conversation between otherUser and anotherUser (not involving $user)
|
||||
$message = $otherUser->sendMessageTo($anotherUser, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
// Try to access via route (should be blocked)
|
||||
$response = $this->get(route('chat', ['conversation' => $conversation->id]));
|
||||
|
||||
// Middleware may return 403 or redirect (302) when unauthorized
|
||||
// Both are acceptable - what matters is user cannot access the conversation
|
||||
$this->assertTrue(
|
||||
in_array($response->status(), [302, 403]),
|
||||
"Expected 302 redirect or 403 forbidden, but got {$response->status()}"
|
||||
);
|
||||
|
||||
// If redirected, should not be to the chat page
|
||||
if ($response->status() === 302) {
|
||||
$this->assertNotEquals(
|
||||
route('chat', ['conversation' => $conversation->id]),
|
||||
$response->headers->get('Location'),
|
||||
'User should not be redirected to the unauthorized conversation'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test route middleware allows authorized conversation access
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function route_middleware_allows_authorized_conversation_access()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Set active profile in session (required by getActiveProfile())
|
||||
session([
|
||||
'activeProfileType' => get_class($user),
|
||||
'activeProfileId' => $user->id,
|
||||
'active_guard' => 'web',
|
||||
]);
|
||||
|
||||
// Create conversation with current user as sender
|
||||
$message = $user->sendMessageTo($recipient, 'Test message');
|
||||
$conversation = $message->conversation;
|
||||
|
||||
// Access via route (should be allowed by middleware)
|
||||
$response = $this->get(route('chat', ['conversation' => $conversation->id]));
|
||||
|
||||
// The participant gets the chat page, not an error page.
|
||||
// (Do not look for a bare '403': checksums and SVG paths contain it.)
|
||||
$response->assertOk();
|
||||
$response->assertDontSee('Forbidden');
|
||||
$response->assertDontSee('Unauthorized action');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authorization;
|
||||
|
||||
use App\Http\Livewire\WireChat\Chat\Chat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* WireChat overrides Livewire's _finishUpload(). Since Livewire 3.8 the upload references it
|
||||
* receives are signed ("token:path"); the override must verify and strip the signature.
|
||||
*
|
||||
* @group security
|
||||
*/
|
||||
class WireChatUploadTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function openChat(): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$recipient = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id, 'active_guard' => 'web']);
|
||||
|
||||
$conversation = $user->sendMessageTo($recipient, 'Hello')->conversation;
|
||||
|
||||
return Livewire::test(Chat::class, ['conversation' => $conversation->id]);
|
||||
}
|
||||
|
||||
/** A temporary upload as Livewire's upload endpoint stores it */
|
||||
private function storeTemporaryUpload(): string
|
||||
{
|
||||
$name = TemporaryUploadedFile::generateHashNameWithOriginalNameEmbedded(UploadedFile::fake()->image('photo.jpg', 40, 40));
|
||||
Storage::disk(FileUploadConfiguration::disk())->putFileAs(
|
||||
FileUploadConfiguration::path(), UploadedFile::fake()->image('photo.jpg', 40, 40), $name
|
||||
);
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function a_signed_upload_reference_becomes_a_usable_file(): void
|
||||
{
|
||||
$name = $this->storeTemporaryUpload();
|
||||
|
||||
$chat = $this->openChat()
|
||||
->call('_finishUpload', 'media', [TemporaryUploadedFile::signPath('/' . $name)], false);
|
||||
|
||||
$media = $chat->instance()->media;
|
||||
$this->assertCount(1, $media);
|
||||
$this->assertSame($name, $media[0]->getFilename());
|
||||
// The file must resolve to the stored upload (sending in the same request reads its size)
|
||||
$this->assertTrue($media[0]->exists());
|
||||
$this->assertGreaterThan(0, $media[0]->getSize());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function uploads_are_appended_like_wirechat_expects(): void
|
||||
{
|
||||
$first = $this->storeTemporaryUpload();
|
||||
$second = $this->storeTemporaryUpload();
|
||||
|
||||
$chat = $this->openChat()
|
||||
->call('_finishUpload', 'media', [TemporaryUploadedFile::signPath('/' . $first)], false)
|
||||
->call('_finishUpload', 'media', [TemporaryUploadedFile::signPath('/' . $second)], false);
|
||||
|
||||
$this->assertSame([$first, $second], collect($chat->instance()->media)->map->getFilename()->all());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function a_tampered_upload_reference_is_refused(): void
|
||||
{
|
||||
$name = $this->storeTemporaryUpload();
|
||||
|
||||
$this->openChat()
|
||||
->call('_finishUpload', 'media', ['deadbeef:/' . $name], false)
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Financial;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Transaction Authorization Tests
|
||||
*
|
||||
* Tests who can create what types of transactions:
|
||||
* - User -> User: Work, Gift (no Donation, no Currency creation/removal)
|
||||
* - User -> Organization: Work, Gift, Donation
|
||||
* - Organization -> User: Work, Gift
|
||||
* - Organization -> Organization: Work, Gift, Donation
|
||||
* - Bank -> Any: Currency creation/removal, all other types
|
||||
* - Internal migrations (same accountable): Migration type enforced
|
||||
*
|
||||
* Tests account ownership validation and balance limits enforcement.
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group financial
|
||||
* @group transaction-authorization
|
||||
*/
|
||||
class TransactionAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// ACCOUNT OWNERSHIP TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that users can only create transactions from accounts they own
|
||||
*/
|
||||
public function test_user_can_only_create_transactions_from_owned_accounts()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$ownedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$unownedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Try to create transaction from unowned account
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $unownedAccount->id,
|
||||
'to_account_id' => $ownedAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized transaction',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected (403 or validation error)
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that organizations can only use their own accounts
|
||||
*/
|
||||
public function test_organization_can_only_use_own_accounts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
|
||||
$user->organizations()->attach($org1->id);
|
||||
|
||||
$org1Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org1->id,
|
||||
]);
|
||||
|
||||
$org2Account = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $org2->id,
|
||||
]);
|
||||
|
||||
// Login as organization1
|
||||
Auth::guard('web')->login($user);
|
||||
Auth::guard('organization')->login($org1);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Try to create transaction from org2's account
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $org2Account->id,
|
||||
'to_account_id' => $org1Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized organization transaction',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that banks can only use their managed accounts
|
||||
*/
|
||||
public function test_bank_can_only_use_managed_accounts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank1 = Bank::factory()->create();
|
||||
$bank2 = Bank::factory()->create();
|
||||
|
||||
$user->banksManaged()->attach($bank1->id);
|
||||
|
||||
$bank1Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank1->id,
|
||||
]);
|
||||
|
||||
$bank2Account = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank2->id,
|
||||
]);
|
||||
|
||||
// Login as bank1
|
||||
Auth::guard('web')->login($user);
|
||||
Auth::guard('bank')->login($bank1);
|
||||
session(['active_guard' => 'bank']);
|
||||
|
||||
// Try to create transaction from bank2's account
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $bank2Account->id,
|
||||
'to_account_id' => $bank1Account->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Unauthorized bank transaction',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TRANSACTION TYPE AUTHORIZATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test User -> User transactions allow Work and Gift types
|
||||
*/
|
||||
public function test_user_to_user_allows_work_and_gift()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Work type (ID 1) should be allowed
|
||||
$transaction1 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Work transaction',
|
||||
'transaction_type_id' => 1, // Work
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction1->id,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Gift type (ID 2) should be allowed
|
||||
$transaction2 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 30,
|
||||
'description' => 'Gift transaction',
|
||||
'transaction_type_id' => 2, // Gift
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction2->id,
|
||||
'transaction_type_id' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test User -> User transactions reject Donation type
|
||||
*/
|
||||
public function test_user_to_user_rejects_donation()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Donation type (ID 3) should be rejected for User -> User
|
||||
// This should be caught by application logic, not just database constraint
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid donation',
|
||||
'transaction_type_id' => 3, // Donation
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
// Exception is also acceptable
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test User -> Organization allows Donation type
|
||||
*/
|
||||
public function test_user_to_organization_allows_donation()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Donation type (ID 3) should be allowed for User -> Organization
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $orgAccount->id,
|
||||
'amount' => 120,
|
||||
'description' => 'Donation to organization',
|
||||
'transaction_type_id' => 3, // Donation
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'transaction_type_id' => 3,
|
||||
'from_account_id' => $userAccount->id,
|
||||
'to_account_id' => $orgAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that regular users cannot create currency creation transactions
|
||||
*/
|
||||
public function test_users_cannot_create_currency_creation_transactions()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Currency creation type (ID 4) should be rejected for regular users
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid currency creation',
|
||||
'transaction_type_id' => 4, // Currency creation
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that regular users cannot create currency removal transactions
|
||||
*/
|
||||
public function test_users_cannot_create_currency_removal_transactions()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Currency removal type (ID 5) should be rejected for regular users
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid currency removal',
|
||||
'transaction_type_id' => 5, // Currency removal
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INTERNAL MIGRATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that internal migrations (same accountable) use Migration type
|
||||
*/
|
||||
public function test_internal_migration_uses_migration_type()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create two accounts for the same user
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'name' => 'time',
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
'name' => 'savings',
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Internal transfer should automatically use Migration type (ID 6)
|
||||
// Even if user tries to specify different type, it should be overridden
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Transfer between own accounts',
|
||||
'transaction_type_id' => 1, // User specifies Work, but should be changed to Migration
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// Verify transaction was created with Migration type
|
||||
// Note: This depends on TransactionController logic enforcing type 6 for internal transfers
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
]);
|
||||
|
||||
// The transaction_type_id should be 6 (Migration) if controller logic is correct
|
||||
// This test documents expected behavior
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BALANCE LIMIT ENFORCEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that transactions respect sender's minimum balance limit
|
||||
*/
|
||||
public function test_transaction_respects_sender_minimum_limit()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
'limit_min' => -100, // Can go 100 minutes into negative
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Current balance: 0
|
||||
// Minimum allowed: -100
|
||||
// Transfer budget: 0 - (-100) = 100 minutes
|
||||
|
||||
// Transaction of 100 minutes should succeed
|
||||
$transaction1 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Max allowed payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', ['id' => $transaction1->id]);
|
||||
|
||||
// Transaction of 1 more minute should fail (would exceed limit)
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 1,
|
||||
'description' => 'Over limit payment',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Should be rejected
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions respect receiver's maximum balance limit
|
||||
*/
|
||||
public function test_transaction_respects_receiver_maximum_limit()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
'limit_min' => -1000, // Large sending capacity
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
'limit_min' => 0,
|
||||
'limit_max' => 100, // Can only receive up to 100 minutes
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Receiver current balance: 0
|
||||
// Receiver maximum allowed: 100
|
||||
// Receiver can accept: 100 - 0 = 100 minutes
|
||||
|
||||
// Transaction of 100 minutes should succeed
|
||||
$transaction1 = Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Max receivable payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', ['id' => $transaction1->id]);
|
||||
|
||||
// Transaction of 1 more minute should fail (would exceed receiver's limit)
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 1,
|
||||
'description' => 'Over receiver limit payment',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that organizations have higher balance limits than users
|
||||
*/
|
||||
public function test_organizations_have_no_lower_limits_than_users()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$userAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$orgAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $organization->id,
|
||||
]);
|
||||
|
||||
// Get limits from configuration
|
||||
$userLimitMax = timebank_config('accounts.user.limit_max');
|
||||
$userLimitMin = timebank_config('accounts.user.limit_min');
|
||||
$orgLimitMax = timebank_config('accounts.organization.limit_max');
|
||||
$orgLimitMin = timebank_config('accounts.organization.limit_min');
|
||||
|
||||
// Limits are platform config (white-label); organizations may not get less room than users
|
||||
$this->assertGreaterThanOrEqual($userLimitMax, $orgLimitMax,
|
||||
'Organizations should not have a lower maximum balance limit than users');
|
||||
|
||||
$this->assertLessThanOrEqual($userLimitMin, $orgLimitMin,
|
||||
'Organizations should not have a higher minimum balance limit than users');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DELETED/INACTIVE ACCOUNT PROTECTION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that transactions cannot be created from deleted accounts
|
||||
*/
|
||||
public function test_cannot_create_transaction_from_deleted_account()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$deletedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
'deleted_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$activeAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Should not allow transaction from deleted account
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $deletedAccount->id,
|
||||
'to_account_id' => $activeAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'From deleted account',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions cannot be created to deleted accounts
|
||||
*/
|
||||
public function test_cannot_create_transaction_to_deleted_account()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$activeAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$deletedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
'deleted_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Should not allow transaction to deleted account
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $activeAccount->id,
|
||||
'to_account_id' => $deletedAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'To deleted account',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions cannot be created from/to inactive accountables
|
||||
*/
|
||||
public function test_cannot_create_transaction_to_inactive_accountable()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create(['inactive_at' => now()->subDay()]);
|
||||
|
||||
$activeAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$inactiveAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Should not allow transaction to account of inactive user
|
||||
try {
|
||||
$response = $this->postJson('/api/transactions', [
|
||||
'from_account_id' => $activeAccount->id,
|
||||
'to_account_id' => $inactiveAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'To inactive user',
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
$this->assertNotEquals(200, $response->status());
|
||||
$this->assertNotEquals(201, $response->status());
|
||||
} catch (\Exception $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TRANSACTION TYPE EXISTENCE TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that all required transaction types exist in database
|
||||
*/
|
||||
public function test_all_transaction_types_exist()
|
||||
{
|
||||
// Required transaction types:
|
||||
// 1. Work
|
||||
// 2. Gift
|
||||
// 3. Donation
|
||||
// 4. Currency creation
|
||||
// 5. Currency removal
|
||||
// 6. Migration
|
||||
// The code refers to these ids directly; they come from the seeder, not the migrations
|
||||
$this->seed(\Database\Seeders\TransactionTypesTableSeeder::class);
|
||||
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 1, 'name' => 'Work']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 2, 'name' => 'Gift']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 3, 'name' => 'Donation']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 4, 'name' => 'Currency creation']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 5, 'name' => 'Currency removal']);
|
||||
$this->assertDatabaseHas('transaction_types', ['id' => 6, 'name' => 'Migration']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions require valid transaction type
|
||||
*/
|
||||
public function test_transaction_requires_valid_type()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Non-existent transaction type should be rejected
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Invalid type',
|
||||
'transaction_type_id' => 999, // Non-existent type
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Financial;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Transaction Integrity Tests
|
||||
*
|
||||
* Tests the database-level immutability and integrity of transactions:
|
||||
* - Transactions can only be created (INSERT) and read (SELECT)
|
||||
* - Transactions cannot be modified (UPDATE) or deleted (DELETE)
|
||||
* - Balance calculations use window functions correctly
|
||||
* - Concurrent transaction handling works properly
|
||||
* - Transaction validation rules are enforced
|
||||
*
|
||||
* CRITICAL FINANCIAL SECURITY: Transaction immutability is enforced at MySQL
|
||||
* user permission level. These tests verify that enforcement.
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group financial
|
||||
* @group transaction-integrity
|
||||
*/
|
||||
class TransactionIntegrityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// DATABASE IMMUTABILITY TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that transactions can be created (INSERT allowed)
|
||||
*/
|
||||
public function test_transactions_can_be_created()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60, // 1 hour
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1, // Work
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions can be read (SELECT allowed)
|
||||
*/
|
||||
public function test_transactions_can_be_read()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 120, // 2 hours
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$retrieved = Transaction::find($transaction->id);
|
||||
|
||||
$this->assertNotNull($retrieved);
|
||||
$this->assertEquals(120, $retrieved->amount);
|
||||
$this->assertEquals('Test payment', $retrieved->description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that attempting to update transactions via Eloquent throws exception
|
||||
*
|
||||
* Note: This tests application-level protection. Database-level protection
|
||||
* (MySQL user permissions) should also prevent UPDATE at DB level.
|
||||
*/
|
||||
public function test_transactions_cannot_be_updated_via_eloquent()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Original description',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$originalAmount = $transaction->amount;
|
||||
$originalDescription = $transaction->description;
|
||||
|
||||
// Try to modify the transaction
|
||||
$transaction->amount = 120;
|
||||
$transaction->description = 'Modified description';
|
||||
|
||||
// If save() succeeds, verify data hasn't changed in database
|
||||
// If MySQL permissions are correctly set, save() should fail
|
||||
try {
|
||||
$transaction->save();
|
||||
|
||||
// If save succeeded, check if data actually changed
|
||||
$transaction->refresh();
|
||||
|
||||
// Transaction should remain unchanged (either save failed silently or DB rejected UPDATE)
|
||||
$this->assertEquals($originalAmount, $transaction->amount,
|
||||
'Transaction amount should not be modifiable');
|
||||
$this->assertEquals($originalDescription, $transaction->description,
|
||||
'Transaction description should not be modifiable');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Expected: UPDATE permission denied
|
||||
$this->assertTrue(true, 'Transaction update correctly prevented');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that attempting to delete transactions via Eloquent throws exception
|
||||
*
|
||||
* Note: This tests application-level protection. Database-level protection
|
||||
* (MySQL user permissions) should also prevent DELETE at DB level.
|
||||
*/
|
||||
public function test_transactions_cannot_be_deleted_via_eloquent()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$transactionId = $transaction->id;
|
||||
|
||||
// Try to delete the transaction
|
||||
try {
|
||||
$transaction->delete();
|
||||
|
||||
// If delete succeeded, verify transaction still exists
|
||||
$this->assertDatabaseHas('transactions', ['id' => $transactionId],
|
||||
'Transaction should not be deletable');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Expected: DELETE permission denied
|
||||
$this->assertTrue(true, 'Transaction deletion correctly prevented');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that raw SQL UPDATE is prevented by database permissions
|
||||
*
|
||||
* This test verifies the MySQL user permission restrictions.
|
||||
*/
|
||||
public function test_raw_sql_update_is_prevented()
|
||||
{
|
||||
$this->skipUnlessDbUserIsRestricted('UPDATE');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Original description',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$originalAmount = $transaction->amount;
|
||||
|
||||
// Try to update via raw SQL
|
||||
try {
|
||||
DB::statement('UPDATE transactions SET amount = ? WHERE id = ?', [120, $transaction->id]);
|
||||
|
||||
// If update succeeded, verify data hasn't changed
|
||||
$transaction->refresh();
|
||||
$this->assertEquals($originalAmount, $transaction->amount,
|
||||
'Raw SQL UPDATE should not modify transaction');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Expected behavior: Permission denied
|
||||
$this->assertStringContainsString('UPDATE command denied', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that raw SQL DELETE is prevented by database permissions
|
||||
*/
|
||||
public function test_raw_sql_delete_is_prevented()
|
||||
{
|
||||
$this->skipUnlessDbUserIsRestricted('DELETE');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$transactionId = $transaction->id;
|
||||
|
||||
// Try to delete via raw SQL
|
||||
try {
|
||||
DB::statement('DELETE FROM transactions WHERE id = ?', [$transactionId]);
|
||||
|
||||
// If delete succeeded, verify transaction still exists
|
||||
$this->assertDatabaseHas('transactions', ['id' => $transactionId],
|
||||
'Raw SQL DELETE should not remove transaction');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Expected behavior: Permission denied
|
||||
$this->assertStringContainsString('DELETE command denied', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BALANCE CALCULATION INTEGRITY TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that balance calculations are correct for single transaction
|
||||
*/
|
||||
public function test_balance_calculation_single_transaction()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
// Initial balances should be 0
|
||||
$this->assertEquals(0, $account1->balance);
|
||||
$this->assertEquals(0, $account2->balance);
|
||||
|
||||
// Create transaction: user1 pays user2 60 minutes
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
// Clear cache to force recalculation
|
||||
\Cache::forget("account_balance_{$account1->id}");
|
||||
\Cache::forget("account_balance_{$account2->id}");
|
||||
|
||||
// Refresh models
|
||||
$account1 = $account1->fresh();
|
||||
$account2 = $account2->fresh();
|
||||
|
||||
// user1 should have -60, user2 should have +60
|
||||
$this->assertEquals(-60, $account1->balance);
|
||||
$this->assertEquals(60, $account2->balance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that balance calculations are correct for multiple transactions
|
||||
*/
|
||||
public function test_balance_calculation_multiple_transactions()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
// Transaction 1: user1 pays user2 60 minutes
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Payment 1',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
// Transaction 2: user2 pays user1 30 minutes
|
||||
Transaction::create([
|
||||
'from_account_id' => $account2->id,
|
||||
'to_account_id' => $account1->id,
|
||||
'amount' => 30,
|
||||
'description' => 'Payment 2',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user2->id,
|
||||
]);
|
||||
|
||||
// Transaction 3: user1 pays user2 15 minutes
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 15,
|
||||
'description' => 'Payment 3',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
// Clear cache
|
||||
\Cache::forget("account_balance_{$account1->id}");
|
||||
\Cache::forget("account_balance_{$account2->id}");
|
||||
|
||||
$account1 = $account1->fresh();
|
||||
$account2 = $account2->fresh();
|
||||
|
||||
// user1: -60 + 30 - 15 = -45
|
||||
// user2: +60 - 30 + 15 = +45
|
||||
$this->assertEquals(-45, $account1->balance);
|
||||
$this->assertEquals(45, $account2->balance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that balance calculations handle concurrent transactions correctly
|
||||
*/
|
||||
public function test_balance_calculation_with_concurrent_transactions()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$user3 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
$account3 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user3->id,
|
||||
]);
|
||||
|
||||
// Simulate multiple transactions happening "simultaneously"
|
||||
DB::transaction(function () use ($account1, $account2, $account3, $user1, $user2, $user3) {
|
||||
// Multiple transfers from account1
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 30,
|
||||
'description' => 'Concurrent payment 1',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account3->id,
|
||||
'amount' => 20,
|
||||
'description' => 'Concurrent payment 2',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $account2->id,
|
||||
'to_account_id' => $account1->id,
|
||||
'amount' => 10,
|
||||
'description' => 'Concurrent payment 3',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user2->id,
|
||||
]);
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
\Cache::forget("account_balance_{$account1->id}");
|
||||
\Cache::forget("account_balance_{$account2->id}");
|
||||
\Cache::forget("account_balance_{$account3->id}");
|
||||
|
||||
$account1 = $account1->fresh();
|
||||
$account2 = $account2->fresh();
|
||||
$account3 = $account3->fresh();
|
||||
|
||||
// account1: -30 - 20 + 10 = -40
|
||||
// account2: +30 - 10 = +20
|
||||
// account3: +20 = +20
|
||||
$this->assertEquals(-40, $account1->balance);
|
||||
$this->assertEquals(20, $account2->balance);
|
||||
$this->assertEquals(20, $account3->balance);
|
||||
|
||||
// Sum of all balances should be 0 (zero-sum system)
|
||||
$totalBalance = $account1->balance + $account2->balance + $account3->balance;
|
||||
$this->assertEquals(0, $totalBalance, 'Total balance in system should always be zero');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TRANSACTION VALIDATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that transactions require valid from_account_id
|
||||
*/
|
||||
public function test_transaction_requires_valid_from_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => 99999, // Non-existent account
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions require valid to_account_id
|
||||
*/
|
||||
public function test_transaction_requires_valid_to_account()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => 99999, // Non-existent account
|
||||
'amount' => 60,
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transactions require positive amount
|
||||
*/
|
||||
public function test_transaction_requires_positive_amount()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
// Negative amounts should be rejected
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => -60, // Negative amount
|
||||
'description' => 'Test payment',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that from_account and to_account must be different
|
||||
*/
|
||||
public function test_transaction_requires_different_accounts()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $account->id,
|
||||
'to_account_id' => $account->id, // Same as from_account
|
||||
'amount' => 60,
|
||||
'description' => 'Self-payment test',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ZERO-SUM INTEGRITY TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that the system maintains zero-sum integrity
|
||||
*
|
||||
* In a timebanking system, the sum of all account balances
|
||||
* should always equal zero (money is neither created nor destroyed
|
||||
* except through special currency creation/removal transactions).
|
||||
*/
|
||||
public function test_system_maintains_zero_sum_integrity()
|
||||
{
|
||||
// Create multiple users and accounts
|
||||
$users = User::factory()->count(5)->create();
|
||||
$accounts = $users->map(function ($user) {
|
||||
return Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
// Create random transactions between accounts
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$fromAccount = $accounts->random();
|
||||
$toAccount = $accounts->where('id', '!=', $fromAccount->id)->random();
|
||||
|
||||
Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => rand(10, 100),
|
||||
'description' => "Random transaction {$i}",
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $users->random()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Clear all balance caches
|
||||
foreach ($accounts as $account) {
|
||||
\Cache::forget("account_balance_{$account->id}");
|
||||
}
|
||||
|
||||
// Calculate sum of all balances
|
||||
$totalBalance = $accounts->sum(function ($account) {
|
||||
return $account->fresh()->balance;
|
||||
});
|
||||
|
||||
// Should be exactly 0 (zero-sum system)
|
||||
$this->assertEquals(0, $totalBalance,
|
||||
'System should maintain zero-sum integrity: sum of all balances must be 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that transaction creation maintains database consistency
|
||||
*/
|
||||
public function test_transaction_creation_maintains_consistency()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
|
||||
$transactionCountBefore = Transaction::count();
|
||||
|
||||
// Create transaction in database transaction
|
||||
DB::transaction(function () use ($account1, $account2, $user1) {
|
||||
Transaction::create([
|
||||
'from_account_id' => $account1->id,
|
||||
'to_account_id' => $account2->id,
|
||||
'amount' => 60,
|
||||
'description' => 'Consistency test',
|
||||
'transaction_type_id' => 1,
|
||||
'creator_user_id' => $user1->id,
|
||||
]);
|
||||
});
|
||||
|
||||
$transactionCountAfter = Transaction::count();
|
||||
|
||||
// Verify transaction was created
|
||||
$this->assertEquals($transactionCountBefore + 1, $transactionCountAfter);
|
||||
|
||||
// Verify transaction is retrievable
|
||||
$transaction = Transaction::latest()->first();
|
||||
$this->assertNotNull($transaction);
|
||||
$this->assertEquals($account1->id, $transaction->from_account_id);
|
||||
$this->assertEquals($account2->id, $transaction->to_account_id);
|
||||
$this->assertEquals(60, $transaction->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutability is enforced by the app's MySQL user, which has no UPDATE/DELETE on
|
||||
* transactions (see deploy.sh). Tests that connect as a user with those rights (root)
|
||||
* cannot check it, so skip rather than report a false failure.
|
||||
*/
|
||||
private function skipUnlessDbUserIsRestricted(string $privilege): void
|
||||
{
|
||||
$grants = collect(DB::select('SHOW GRANTS FOR CURRENT_USER()'))
|
||||
->map(fn ($row) => strtoupper(implode(' ', (array) $row)));
|
||||
|
||||
$mayModify = $grants->contains(function ($grant) use ($privilege) {
|
||||
$onAllOrTransactions = str_contains($grant, ' ON *.*') || str_contains($grant, '.`TRANSACTIONS`') || str_contains($grant, '.*');
|
||||
return $onAllOrTransactions && (str_contains($grant, 'ALL PRIVILEGES') || preg_match('/\\b' . $privilege . '\\b/', $grant));
|
||||
});
|
||||
|
||||
if ($mayModify) {
|
||||
$this->markTestSkipped("The test DB user may {$privilege} transactions; run with the app's DB user to check immutability.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\IDOR;
|
||||
|
||||
use App\Http\Livewire\Profiles\Manage as ProfilesManage;
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Bank-manager Accounts-scope IDOR Tests
|
||||
*
|
||||
* A regular (non-central) bank-manager can now open the Profiles\Manage
|
||||
* component to edit accounts / issue reversals for their own bank's clients.
|
||||
* These tests verify that scoping is enforced server-side and cannot be
|
||||
* bypassed by manipulating the profile ID / model name passed to Livewire
|
||||
* methods, and that non-accounts actions stay out of reach entirely.
|
||||
*
|
||||
* @group security
|
||||
* @group idor
|
||||
* @group critical
|
||||
*/
|
||||
class BankManagerAccountsScopeIDORTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// organization_roles has no migration in this repo (pre-existing gap —
|
||||
// the table only exists on the dev DB, created out-of-band). Create it
|
||||
// here so RefreshDatabase-based tests can exercise OrganizationRole::
|
||||
// lookups (Manage.php queries this table when rendering the modals).
|
||||
if (!DB::getSchemaBuilder()->hasTable('organization_roles')) {
|
||||
DB::getSchemaBuilder()->create('organization_roles', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('label');
|
||||
$table->longText('permissions');
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->timestamps();
|
||||
$table->unique('name');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the given user into the web guard (Spatie roles live on User) and
|
||||
* the given bank into the bank guard (the active elevated profile),
|
||||
* mirroring how SwitchGuardTrait leaves both guards authenticated.
|
||||
*/
|
||||
private function actingAsBankManager(User $user, Bank $bank): void
|
||||
{
|
||||
$role = Role::firstOrCreate([
|
||||
'name' => "Bank\\{$bank->id}\\bank-manager",
|
||||
'guard_name' => 'web',
|
||||
]);
|
||||
$user->assignRole($role);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
}
|
||||
|
||||
private function attachClient(Bank $bank, $client): void
|
||||
{
|
||||
DB::table('bank_clients')->insert([
|
||||
'bank_id' => $bank->id,
|
||||
'client_type' => get_class($client),
|
||||
'client_id' => $client->id,
|
||||
'relationship_type' => 'local',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_manager_can_open_accounts_for_own_bank_client()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create(); // level defaults to 1 (non-central)
|
||||
|
||||
$client = User::factory()->create();
|
||||
$this->attachClient($bank, $client);
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $client->id,
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $client->id, User::class);
|
||||
|
||||
$component->assertStatus(200);
|
||||
$component->assertHasNoErrors();
|
||||
$this->assertSame($client->id, $component->get('editAccounts')['id']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_manager_cannot_open_accounts_for_another_banks_client()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bankA = Bank::factory()->create();
|
||||
$bankB = Bank::factory()->create();
|
||||
|
||||
// Client belongs to Bank B, not Bank A
|
||||
$foreignClient = User::factory()->create();
|
||||
$this->attachClient($bankB, $foreignClient);
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $foreignClient->id,
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bankA);
|
||||
|
||||
// IDOR attempt: manager of Bank A tries to open Bank B's client accounts
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $foreignClient->id, User::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_manager_cannot_update_accounts_for_another_banks_client_via_tampered_state()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bankA = Bank::factory()->create();
|
||||
$bankB = Bank::factory()->create();
|
||||
|
||||
$ownClient = User::factory()->create();
|
||||
$this->attachClient($bankA, $ownClient);
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $ownClient->id,
|
||||
]);
|
||||
|
||||
$foreignClient = User::factory()->create();
|
||||
$this->attachClient($bankB, $foreignClient);
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $foreignClient->id,
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bankA);
|
||||
|
||||
// Legitimately open own client's accounts modal first...
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $ownClient->id, User::class);
|
||||
$component->assertStatus(200);
|
||||
|
||||
// ...then tamper with the Livewire-exposed editAccounts state to point
|
||||
// at Bank B's client before submitting the save action.
|
||||
$component->set('editAccounts.id', $foreignClient->id);
|
||||
$component->set('editAccounts.modelClass', User::class);
|
||||
|
||||
$component->call('updateAccounts');
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_manager_cannot_access_clients_of_organization_type_belonging_to_another_bank()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bankA = Bank::factory()->create();
|
||||
$bankB = Bank::factory()->create();
|
||||
|
||||
$foreignOrgClient = Organization::factory()->create();
|
||||
$this->attachClient($bankB, $foreignOrgClient);
|
||||
Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $foreignOrgClient->id,
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bankA);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $foreignOrgClient->id, Organization::class);
|
||||
|
||||
$component->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_manager_cannot_reach_non_accounts_profile_actions()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
|
||||
$ownClient = User::factory()->create();
|
||||
$this->attachClient($bank, $ownClient);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
// Profile edit, profile links, and delete/restore must all stay out of
|
||||
// reach for a scoped (non-central) bank-manager, even for their own client.
|
||||
Livewire::test(ProfilesManage::class)
|
||||
->call('openEditProfileModal', $ownClient->id, User::class)
|
||||
->assertStatus(403);
|
||||
|
||||
Livewire::test(ProfilesManage::class)
|
||||
->call('openAttachProfilesModal', $ownClient->id, User::class)
|
||||
->assertStatus(403);
|
||||
|
||||
Livewire::test(ProfilesManage::class)
|
||||
->call('openDeleteProfileModal', $ownClient->id, User::class)
|
||||
->assertStatus(403);
|
||||
|
||||
Livewire::test(ProfilesManage::class)
|
||||
->call('openRestoreProfileModal', $ownClient->id, User::class)
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function reversal_claws_back_from_recipient_and_restores_the_sender()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
// Both sender (Organization) and recipient (User) are clients of the
|
||||
// same bank, mirroring the real TTT -> Ronald scenario.
|
||||
$sender = Organization::factory()->create();
|
||||
$this->attachClient($bank, $sender);
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$this->attachClient($bank, $recipient);
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$original = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 1, // Work
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
// Bank-manager opens the RECIPIENT's accounts modal (as would happen
|
||||
// if they were looking at Ronald's account, not TTT's), then looks up
|
||||
// the transaction by ID rather than picking from a per-account list.
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $recipient->id, User::class)
|
||||
->call('openReversalModal');
|
||||
$component->assertStatus(200);
|
||||
|
||||
$component->set('reversalLookupId', (string) $original->id);
|
||||
$component->call('lookupReversalTransaction');
|
||||
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
||||
|
||||
// Both the sender and recipient must be offered as requester options.
|
||||
$requesterOptions = $component->get('reversalRequesterOptions');
|
||||
$this->assertCount(2, $requesterOptions);
|
||||
$requesterTypesAndIds = collect($requesterOptions)->map(fn ($o) => [$o['type'], $o['id']])->all();
|
||||
$this->assertContains([Organization::class, $sender->id], $requesterTypesAndIds);
|
||||
$this->assertContains([User::class, $recipient->id], $requesterTypesAndIds);
|
||||
|
||||
$chosenRequester = collect($requesterOptions)->firstWhere('id', $recipient->id);
|
||||
$component->set('reversalRequestedBy', $chosenRequester['key']);
|
||||
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component->call('issueReversal');
|
||||
|
||||
$component->assertStatus(200);
|
||||
|
||||
// Leg A: recipient -> bank (claw back what they wrongly received)
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $recipientAccount->id,
|
||||
'to_account_id' => $bankAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
|
||||
// Leg B: bank -> sender (restore what they originally paid out)
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $bankAccount->id,
|
||||
'to_account_id' => $senderAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
|
||||
// Both new transactions' descriptions mention the chosen requester.
|
||||
$legA = DB::table('transactions')->where('from_account_id', $recipientAccount->id)->where('to_account_id', $bankAccount->id)->where('transaction_type_id', 8)->first();
|
||||
$legB = DB::table('transactions')->where('from_account_id', $bankAccount->id)->where('to_account_id', $senderAccount->id)->where('transaction_type_id', 8)->first();
|
||||
$this->assertStringContainsString($recipient->full_name ?? $recipient->name, $legA->description);
|
||||
$this->assertStringContainsString($recipient->full_name ?? $recipient->name, $legB->description);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function issuing_a_reversal_requires_a_valid_requester_from_the_transactions_two_parties()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$sender = Organization::factory()->create();
|
||||
$this->attachClient($bank, $sender);
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$this->attachClient($bank, $recipient);
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$original = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 1, // Work
|
||||
]);
|
||||
|
||||
$unrelatedThirdParty = User::factory()->create();
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $recipient->id, User::class)
|
||||
->call('openReversalModal');
|
||||
$component->set('reversalLookupId', (string) $original->id);
|
||||
$component->call('lookupReversalTransaction');
|
||||
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
||||
|
||||
// No requester selected at all — must be rejected.
|
||||
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component->call('issueReversal');
|
||||
$this->assertSame(0, DB::table('transactions')->where('transaction_type_id', 8)->count());
|
||||
|
||||
// A requester that is NOT one of the transaction's two actual parties
|
||||
// — must also be rejected, even though it's a real profile.
|
||||
$component->set('reversalRequestedBy', 'User:' . $unrelatedThirdParty->id);
|
||||
$component->call('issueReversal');
|
||||
$this->assertSame(0, DB::table('transactions')->where('transaction_type_id', 8)->count(), 'A requester who is not party to the transaction must not be accepted.');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function a_transaction_cannot_be_reversed_twice()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$sender = Organization::factory()->create();
|
||||
$this->attachClient($bank, $sender);
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
$recipient = User::factory()->create();
|
||||
$this->attachClient($bank, $recipient);
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$original = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 1, // Work
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
// First reversal — should succeed normally.
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $recipient->id, User::class)
|
||||
->call('openReversalModal');
|
||||
$component->set('reversalLookupId', (string) $original->id);
|
||||
$component->call('lookupReversalTransaction');
|
||||
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
||||
$component->set('reversalRequestedBy', $component->get('reversalRequesterOptions')[0]['key']);
|
||||
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component->call('issueReversal');
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $recipientAccount->id,
|
||||
'to_account_id' => $bankAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
$this->assertSame(2, DB::table('transactions')->where('transaction_type_id', 8)->count());
|
||||
|
||||
// Reopen the modal and look up the same original transaction again —
|
||||
// the lookup must reject it as already reversed...
|
||||
$component2 = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $recipient->id, User::class)
|
||||
->call('openReversalModal');
|
||||
|
||||
$component2->set('reversalLookupId', (string) $original->id);
|
||||
$component2->call('lookupReversalTransaction');
|
||||
$component2->assertSet('reversalSelectedTransactionId', null);
|
||||
$this->assertNotEmpty($component2->get('reversalLookupError'));
|
||||
|
||||
// ...and even if forced via tampered state, issueReversal() must reject
|
||||
// it rather than create a second (opposite-direction) reversal pair
|
||||
// that would silently undo the first one.
|
||||
$component2->set('reversalSelectedTransactionId', $original->id);
|
||||
$component2->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component2->call('issueReversal');
|
||||
|
||||
$this->assertSame(2, DB::table('transactions')->where('transaction_type_id', 8)->count(), 'A second reversal of the same transaction must not be created.');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_coordinator_cannot_open_accounts_management_at_all()
|
||||
{
|
||||
$coordinator = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
|
||||
$client = User::factory()->create();
|
||||
$this->attachClient($bank, $client);
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $client->id,
|
||||
]);
|
||||
|
||||
// bank-coordinator role instead of bank-manager
|
||||
$role = Role::firstOrCreate([
|
||||
'name' => "Bank\\{$bank->id}\\bank-coordinator",
|
||||
'guard_name' => 'web',
|
||||
]);
|
||||
$coordinator->assignRole($role);
|
||||
|
||||
$this->actingAs($coordinator, 'web');
|
||||
$this->actingAs($bank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $bank->id]);
|
||||
|
||||
// mount() itself should reject: not central bank, not bank-manager
|
||||
Livewire::test(ProfilesManage::class)->assertStatus(403);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function reversal_is_authorized_when_only_the_recipient_is_in_scope()
|
||||
{
|
||||
// The rule is "sender OR recipient in scope" — a bank-manager can
|
||||
// still reverse a transaction where only the RECIPIENT is their own
|
||||
// client, even if the sender belongs to (or is unrelated to) another
|
||||
// bank entirely.
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$recipientClient = User::factory()->create();
|
||||
$this->attachClient($bank, $recipientClient);
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $recipientClient->id,
|
||||
]);
|
||||
|
||||
$unrelatedSender = Organization::factory()->create();
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $unrelatedSender->id,
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 1, // Work
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $recipientClient->id, User::class)
|
||||
->call('openReversalModal');
|
||||
|
||||
$component->set('reversalLookupId', (string) $transaction->id);
|
||||
$component->call('lookupReversalTransaction');
|
||||
$component->assertSet('reversalSelectedTransactionId', $transaction->id);
|
||||
|
||||
$component->set('reversalRequestedBy', $component->get('reversalRequesterOptions')[0]['key']);
|
||||
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component->call('issueReversal');
|
||||
|
||||
$component->assertStatus(200);
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $recipientAccount->id,
|
||||
'to_account_id' => $bankAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $bankAccount->id,
|
||||
'to_account_id' => $senderAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function reversal_is_rejected_when_neither_party_is_in_scope()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
// Neither the sender nor the recipient is a client of this bank —
|
||||
// the viewed account itself belongs to a DIFFERENT client of this
|
||||
// bank, and the transaction merely happens to be one of its rows.
|
||||
$viewedClient = User::factory()->create();
|
||||
$this->attachClient($bank, $viewedClient);
|
||||
$viewedAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $viewedClient->id,
|
||||
]);
|
||||
|
||||
$outOfScopeRecipient = User::factory()->create();
|
||||
$recipientAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $outOfScopeRecipient->id,
|
||||
]);
|
||||
|
||||
$outOfScopeSender = Organization::factory()->create();
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $outOfScopeSender->id,
|
||||
]);
|
||||
|
||||
// A transaction between two out-of-scope parties, unrelated to the
|
||||
// viewed account, forced directly by ID (simulating tampered state
|
||||
// that bypassed the normal list-building/filtering).
|
||||
$transaction = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 1, // Work
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $viewedClient->id, User::class)
|
||||
->call('openReversalModal');
|
||||
$component->assertStatus(200);
|
||||
|
||||
// The lookup itself must fail: this transaction touches neither of
|
||||
// the viewed profile's accounts, so it's not found for this profile.
|
||||
$component->set('reversalLookupId', (string) $transaction->id);
|
||||
$component->call('lookupReversalTransaction');
|
||||
$component->assertSet('reversalSelectedTransactionId', null);
|
||||
$this->assertNotEmpty($component->get('reversalLookupError'));
|
||||
|
||||
// Even if forced via tampered state, issueReversal() must still reject
|
||||
// it rather than create a reversal.
|
||||
$component->set('reversalSelectedTransactionId', $transaction->id);
|
||||
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component->call('issueReversal');
|
||||
$component->assertStatus(403);
|
||||
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'from_account_id' => $recipientAccount->id,
|
||||
'to_account_id' => $senderAccount->id,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
$this->assertDatabaseMissing('transactions', [
|
||||
'to_account_id' => $senderAccount->id,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function reversal_lookup_finds_a_transaction_regardless_of_which_of_the_profiles_accounts_it_is_on()
|
||||
{
|
||||
// A profile can have more than one account of the same accountable
|
||||
// type (a real-world data anomaly seen in production — e.g. two
|
||||
// duplicate "organization" accounts for the same Organization). The
|
||||
// old per-account button/list only ever searched ONE account at a
|
||||
// time, so a transaction on the "other" account was invisible. The
|
||||
// ID lookup must check ALL of the profile's accounts.
|
||||
$manager = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$bankAccount = Account::factory()->create([
|
||||
'accountable_type' => Bank::class,
|
||||
'accountable_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$sender = User::factory()->create();
|
||||
$this->attachClient($bank, $sender);
|
||||
|
||||
$recipient = Organization::factory()->create();
|
||||
$this->attachClient($bank, $recipient);
|
||||
// Two separate accounts for the same recipient Organization.
|
||||
$recipientAccountA = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
$recipientAccountB = Account::factory()->create([
|
||||
'accountable_type' => Organization::class,
|
||||
'accountable_id' => $recipient->id,
|
||||
]);
|
||||
|
||||
$senderAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $sender->id,
|
||||
]);
|
||||
|
||||
// The transaction landed on the SECOND account (accountAccountB),
|
||||
// not the one the accounts-edit modal would default to viewing.
|
||||
$original = Transaction::factory()->create([
|
||||
'from_account_id' => $senderAccount->id,
|
||||
'to_account_id' => $recipientAccountB->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 1, // Work
|
||||
]);
|
||||
|
||||
$this->actingAsBankManager($manager, $bank);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class)
|
||||
->call('openEditAccountsModal', $recipient->id, Organization::class)
|
||||
->call('openReversalModal');
|
||||
$component->assertStatus(200);
|
||||
|
||||
$component->set('reversalLookupId', (string) $original->id);
|
||||
$component->call('lookupReversalTransaction');
|
||||
$component->assertSet('reversalSelectedTransactionId', $original->id);
|
||||
$this->assertEmpty($component->get('reversalLookupError'));
|
||||
|
||||
$component->set('reversalRequestedBy', $component->get('reversalRequesterOptions')[0]['key']);
|
||||
$component->set('reversalConfirmString', strtolower(__('messages.confirm_input_string')));
|
||||
$component->call('issueReversal');
|
||||
$component->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $recipientAccountB->id,
|
||||
'to_account_id' => $bankAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'from_account_id' => $bankAccount->id,
|
||||
'to_account_id' => $senderAccount->id,
|
||||
'amount' => 5,
|
||||
'transaction_type_id' => 8,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function central_bank_mount_is_never_scoped_regardless_of_bank_clients()
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$centralBank = Bank::factory()->create(['level' => 0]);
|
||||
|
||||
// A client that is NOT linked to this bank via bank_clients at all —
|
||||
// central bank access must not depend on the bank_clients pivot, and
|
||||
// mount() must not scope a central bank the way it scopes a regular
|
||||
// bank-manager (no scopedToBankId set at all).
|
||||
$unlinkedUser = User::factory()->create();
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $unlinkedUser->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($manager, 'web');
|
||||
$this->actingAs($centralBank, 'bank');
|
||||
session(['activeProfileType' => Bank::class, 'activeProfileId' => $centralBank->id]);
|
||||
|
||||
$component = Livewire::test(ProfilesManage::class);
|
||||
$component->assertStatus(200);
|
||||
$this->assertNull($component->get('scopedToBankId'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\IDOR;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Post;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Profile Access IDOR (Insecure Direct Object Reference) Tests
|
||||
*
|
||||
* Tests that users cannot access resources belonging to other users
|
||||
* by manipulating IDs in requests.
|
||||
*
|
||||
* @group security
|
||||
* @group idor
|
||||
* @group critical
|
||||
*/
|
||||
class ProfileAccessIDORTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test user cannot view another user's private profile data via ID manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_view_another_users_private_profile_via_id()
|
||||
{
|
||||
// Arrange: Create two users
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Act: Login as user1, try to access user2's profile edit page
|
||||
$response = $this->actingAs($user1)
|
||||
->get(route('profile.edit'));
|
||||
|
||||
// Try to load user2's data by manipulating session
|
||||
session(['activeProfileId' => $user2->id]);
|
||||
|
||||
$response2 = $this->actingAs($user1)
|
||||
->get(route('profile.edit'));
|
||||
|
||||
// Assert: Should not be able to see user2's private data
|
||||
// This is a basic check - deeper validation would require inspecting response content
|
||||
$this->assertTrue(true); // Placeholder for more specific assertions
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access another user's account balance via ID manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_another_users_account_balance()
|
||||
{
|
||||
// Arrange: Create two users with accounts
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$account1 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
$account3 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
// A transaction of user2 that does not involve user1
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $account2->id,
|
||||
'to_account_id' => $account3->id,
|
||||
'amount' => 100,
|
||||
'description' => 'Private to user2',
|
||||
]);
|
||||
|
||||
// Act: Login as user1 and point the account list at user2's account
|
||||
$this->actingAs($user1, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user1->id]);
|
||||
|
||||
// Assert: the account must belong to the active profile, so nothing of user2 is listed
|
||||
Livewire::test(\App\Http\Livewire\TransactionsTable::class)
|
||||
->set('fromAccountId', $account2->id)
|
||||
->assertDontSee('Private to user2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot view another user's transaction history via ID manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_view_another_users_transaction_history()
|
||||
{
|
||||
// Arrange: a transaction between user2 and user3, not involving user1
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$user3 = User::factory()->create();
|
||||
|
||||
Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user1->id,
|
||||
]);
|
||||
$account2 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user2->id,
|
||||
]);
|
||||
$account3 = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user3->id,
|
||||
]);
|
||||
|
||||
$privateTransaction = Transaction::factory()->create([
|
||||
'from_account_id' => $account2->id,
|
||||
'to_account_id' => $account3->id,
|
||||
'amount' => 500,
|
||||
'description' => 'Private transaction between user2 and user3',
|
||||
]);
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user1->id]);
|
||||
|
||||
// The own history is available
|
||||
$this->get(route('transactions'))->assertSuccessful();
|
||||
|
||||
// Opening the other transaction's statement by id is refused
|
||||
$response = $this->get(route('transaction.show', ['transactionId' => $privateTransaction->id]));
|
||||
$response->assertForbidden();
|
||||
$response->assertDontSee('Private transaction between user2 and user3');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot edit another user's post via ID manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_edit_another_users_post()
|
||||
{
|
||||
// Posts are edited in the post manager, which is for admins and the central bank only;
|
||||
// a regular user cannot open it, so no post of another profile can be edited
|
||||
$user1 = User::factory()->create();
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user1->id]);
|
||||
|
||||
$this->get(route('posts.manage'))->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access organization dashboard they're not linked to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_organization_dashboard_without_link()
|
||||
{
|
||||
// Arrange: Create user and two organizations
|
||||
$user = User::factory()->create();
|
||||
$org1 = Organization::factory()->create();
|
||||
$org2 = Organization::factory()->create();
|
||||
|
||||
// Link user to org1 only
|
||||
$user->organizations()->attach($org1->id);
|
||||
|
||||
// Act: Login as user
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Point the session at org2 (not linked)
|
||||
session(['activeProfileType' => 'App\\Models\\Organization']);
|
||||
session(['activeProfileId' => $org2->id]); // IDOR attempt
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Assert: the page is not served and the organization guard stays logged out
|
||||
$this->get(route('main'))->assertRedirect();
|
||||
$this->assertGuest('organization');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access bank dashboard they're not linked to
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function user_cannot_access_bank_dashboard_without_link()
|
||||
{
|
||||
// Arrange: Create user and two banks
|
||||
$user = User::factory()->create();
|
||||
$bank1 = Bank::factory()->create();
|
||||
$bank2 = Bank::factory()->create();
|
||||
|
||||
// Link user to bank1 only
|
||||
$user->banksManaged()->attach($bank1->id);
|
||||
|
||||
// Act: Login as user
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Point the session at bank2 (not linked)
|
||||
session(['activeProfileType' => 'App\\Models\\Bank']);
|
||||
session(['activeProfileId' => $bank2->id]); // IDOR attempt
|
||||
session(['active_guard' => 'bank']);
|
||||
|
||||
// Assert: the page is not served and the bank guard stays logged out
|
||||
$this->get(route('main'))->assertRedirect();
|
||||
$this->assertGuest('bank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user cannot access admin panel by ID manipulation
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function regular_user_cannot_access_admin_panel()
|
||||
{
|
||||
// Arrange: Create regular user and admin
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
// Act: Login as regular user
|
||||
$this->actingAs($user);
|
||||
|
||||
// Try to manipulate session to become admin
|
||||
session(['activeProfileType' => 'App\\Models\\Admin']);
|
||||
session(['activeProfileId' => $admin->id]); // IDOR attempt
|
||||
session(['active_guard' => 'admin']);
|
||||
|
||||
// Try to access admin dashboard
|
||||
$response = $this->get('/admin/dashboard');
|
||||
|
||||
// Assert: Should be denied
|
||||
$this->assertTrue(
|
||||
$response->status() === 403 || $response->status() === 302 || $response->status() === 404,
|
||||
"Regular user should not access admin panel"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API endpoints validate ownership (if applicable)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function api_endpoints_validate_resource_ownership()
|
||||
{
|
||||
// Arrange: Create two users
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Act: Login as user1
|
||||
$this->actingAs($user1);
|
||||
|
||||
// Try to access user2's data via API (if API exists)
|
||||
// This is a placeholder test - actual implementation depends on API structure
|
||||
|
||||
// Assert: Proper 403 or ownership validation
|
||||
$this->assertTrue(true); // Placeholder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Presence;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Services\PresenceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Presence System Security Tests
|
||||
*
|
||||
* Tests security aspects of the presence system to ensure:
|
||||
* - Users cannot spoof presence of other users
|
||||
* - Guard separation is enforced
|
||||
* - No IDOR vulnerabilities in presence updates
|
||||
* - No sensitive data exposed through presence system
|
||||
* - Cache poisoning prevention
|
||||
*
|
||||
* @group security
|
||||
* @group presence
|
||||
* @group critical
|
||||
*/
|
||||
class PresenceSystemSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected PresenceService $presenceService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->presenceService = app(PresenceService::class);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// IDOR PREVENTION TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_update_presence_for_another_user()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Authenticate as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Update presence (should only affect authenticated user)
|
||||
$this->presenceService->updatePresence();
|
||||
|
||||
// Verify only user1's presence was updated, not user2's
|
||||
$this->assertTrue($this->presenceService->isUserOnline($user1, 'web'));
|
||||
|
||||
// Check activities to verify user2 has no presence logged
|
||||
$user2Activities = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user2->id)
|
||||
->where('subject_type', get_class($user2))
|
||||
->count();
|
||||
|
||||
$this->assertEquals(0, $user2Activities, 'User2 should have no presence activities');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function presence_update_accepts_null_and_uses_authenticated_user()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Call without parameters - should use Auth::user()
|
||||
$this->presenceService->updatePresence();
|
||||
|
||||
// Verify the authenticated user's presence was updated
|
||||
$latestActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($latestActivity, 'Authenticated user presence should be logged');
|
||||
$this->assertEquals($user->id, $latestActivity->subject_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function unauthenticated_user_cannot_update_presence()
|
||||
{
|
||||
// No authentication
|
||||
$this->presenceService->updatePresence();
|
||||
|
||||
// Verify no presence activities were created
|
||||
$activityCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->count();
|
||||
|
||||
$this->assertEquals(0, $activityCount, 'Unauthenticated users should not create presence records');
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// GUARD SEPARATION TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function presence_is_guard_specific()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Set user online on web guard
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Verify user is online on web guard
|
||||
$this->assertTrue($this->presenceService->isUserOnline($user, 'web'));
|
||||
|
||||
// Check activity logs directly to verify guard separation
|
||||
$webActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->where('properties->guard', 'web')
|
||||
->exists();
|
||||
|
||||
$adminActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->where('properties->guard', 'admin')
|
||||
->exists();
|
||||
|
||||
$this->assertTrue($webActivity, 'Should have activity on web guard');
|
||||
$this->assertFalse($adminActivity, 'Should NOT have activity on admin guard');
|
||||
|
||||
// Now log presence on a different guard for the same user
|
||||
$this->presenceService->updatePresence($user, 'admin');
|
||||
|
||||
// Now both guards should have activities
|
||||
$adminActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->where('properties->guard', 'admin')
|
||||
->exists();
|
||||
|
||||
$this->assertTrue($adminActivity, 'Should now have activity on admin guard');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function online_users_list_is_guard_specific()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
|
||||
// Set different profiles online on different guards
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
$this->actingAs($organization, 'organization');
|
||||
$this->presenceService->updatePresence($organization, 'organization');
|
||||
|
||||
$this->actingAs($bank, 'bank');
|
||||
$this->presenceService->updatePresence($bank, 'bank');
|
||||
|
||||
// Get online users per guard
|
||||
$webOnlineUsers = $this->presenceService->getOnlineUsers('web');
|
||||
$orgOnlineUsers = $this->presenceService->getOnlineUsers('organization');
|
||||
$bankOnlineUsers = $this->presenceService->getOnlineUsers('bank');
|
||||
|
||||
// Verify guard separation
|
||||
$this->assertEquals(1, $webOnlineUsers->count());
|
||||
$this->assertEquals($user->id, $webOnlineUsers->first()['id']);
|
||||
|
||||
$this->assertEquals(1, $orgOnlineUsers->count());
|
||||
$this->assertEquals($organization->id, $orgOnlineUsers->first()['id']);
|
||||
|
||||
$this->assertEquals(1, $bankOnlineUsers->count());
|
||||
$this->assertEquals($bank->id, $bankOnlineUsers->first()['id']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_spoof_guard_in_presence_update()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Authenticate as web user
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to update presence with wrong guard
|
||||
$this->presenceService->updatePresence($user, 'admin');
|
||||
|
||||
// Verify the activity was still logged with the specified guard
|
||||
// (This is by design - the function trusts the passed guard parameter)
|
||||
// But the key security is that Auth::user() is always used, not a passed user
|
||||
$activity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($activity);
|
||||
|
||||
// The guard parameter is logged as-is, but this is not a security issue
|
||||
// because the authenticated user (from Auth) is what matters
|
||||
$properties = $activity->properties;
|
||||
$this->assertEquals('admin', $properties['guard']);
|
||||
|
||||
// The important security check: the subject is still the authenticated web user
|
||||
$this->assertEquals(get_class($user), $activity->subject_type);
|
||||
$this->assertEquals($user->id, $activity->subject_id);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// CACHE POISONING PREVENTION
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function cache_keys_are_guard_specific_preventing_poisoning()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Get cache keys
|
||||
$webCacheKey = "presence_web_{$user->id}";
|
||||
$adminCacheKey = "presence_admin_{$user->id}";
|
||||
|
||||
// Verify web cache exists
|
||||
$webCache = Cache::get($webCacheKey);
|
||||
$this->assertNotNull($webCache);
|
||||
$this->assertEquals('web', $webCache['guard']);
|
||||
|
||||
// Update on different guard
|
||||
$this->presenceService->updatePresence($user, 'admin');
|
||||
|
||||
// Verify admin cache now exists with correct guard
|
||||
$adminCache = Cache::get($adminCacheKey);
|
||||
$this->assertNotNull($adminCache);
|
||||
$this->assertEquals('admin', $adminCache['guard']);
|
||||
|
||||
// Verify they are separate cache entries
|
||||
$this->assertNotEquals($webCache['guard'], $adminCache['guard']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function offline_status_clears_cache()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Verify cache exists
|
||||
$cacheKey = "presence_web_{$user->id}";
|
||||
$this->assertNotNull(Cache::get($cacheKey));
|
||||
|
||||
// Set user offline
|
||||
$this->presenceService->setUserOffline($user, 'web');
|
||||
|
||||
// Verify cache was cleared
|
||||
$this->assertNull(Cache::get($cacheKey));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function online_users_cache_has_reasonable_ttl()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Get online users (this caches the result)
|
||||
$onlineUsers = $this->presenceService->getOnlineUsers('web');
|
||||
|
||||
// Verify cache key exists
|
||||
$cacheKey = "online_users_web_" . PresenceService::ONLINE_THRESHOLD_MINUTES;
|
||||
$this->assertNotNull(Cache::get($cacheKey));
|
||||
|
||||
// Cache TTL is 30 seconds (defined in the service)
|
||||
// This is reasonable to prevent stale data while avoiding excessive queries
|
||||
$this->assertEquals(1, $onlineUsers->count());
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// DATA EXPOSURE PREVENTION
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function presence_data_does_not_expose_sensitive_information()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'sensitive@example.com',
|
||||
'password' => bcrypt('secret123'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Get online users data
|
||||
$onlineUsers = $this->presenceService->getOnlineUsers('web');
|
||||
$userData = $onlineUsers->first();
|
||||
|
||||
// Verify only safe data is exposed
|
||||
$this->assertArrayHasKey('id', $userData);
|
||||
$this->assertArrayHasKey('name', $userData);
|
||||
$this->assertArrayHasKey('avatar', $userData);
|
||||
$this->assertArrayHasKey('guard', $userData);
|
||||
$this->assertArrayHasKey('last_seen', $userData);
|
||||
$this->assertArrayHasKey('status', $userData);
|
||||
|
||||
// Verify sensitive data is NOT exposed
|
||||
$this->assertArrayNotHasKey('email', $userData);
|
||||
$this->assertArrayNotHasKey('password', $userData);
|
||||
$this->assertArrayNotHasKey('remember_token', $userData);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function presence_cache_does_not_expose_sensitive_information()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'sensitive@example.com',
|
||||
'password' => bcrypt('secret123'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Get cached data
|
||||
$cacheKey = "presence_web_{$user->id}";
|
||||
$cachedData = Cache::get($cacheKey);
|
||||
|
||||
// Verify only safe data is cached
|
||||
$this->assertArrayHasKey('user_id', $cachedData);
|
||||
$this->assertArrayHasKey('user_type', $cachedData);
|
||||
$this->assertArrayHasKey('guard', $cachedData);
|
||||
$this->assertArrayHasKey('name', $cachedData);
|
||||
$this->assertArrayHasKey('avatar', $cachedData);
|
||||
$this->assertArrayHasKey('last_seen', $cachedData);
|
||||
$this->assertArrayHasKey('status', $cachedData);
|
||||
|
||||
// Verify sensitive data is NOT cached
|
||||
$this->assertArrayNotHasKey('email', $cachedData);
|
||||
$this->assertArrayNotHasKey('password', $cachedData);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function presence_activity_log_does_not_expose_passwords()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => bcrypt('secret123'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Get activity log
|
||||
$activity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// Verify properties don't contain sensitive data
|
||||
$properties = $activity->properties;
|
||||
$this->assertArrayNotHasKey('password', $properties->toArray());
|
||||
$this->assertArrayNotHasKey('email', $properties->toArray());
|
||||
|
||||
// Verify only metadata is logged
|
||||
$this->assertArrayHasKey('guard', $properties);
|
||||
$this->assertArrayHasKey('status', $properties);
|
||||
$this->assertArrayHasKey('ip_address', $properties);
|
||||
$this->assertArrayHasKey('user_agent', $properties);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// MULTI-GUARD PROFILE TESTS
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function admin_presence_is_tracked_separately_from_user()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Set admin online on admin guard
|
||||
$this->actingAs($admin, 'admin');
|
||||
$this->presenceService->updatePresence($admin, 'admin');
|
||||
|
||||
// Set user online on web guard
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Verify each is tracked on their own guard
|
||||
$this->assertTrue($this->presenceService->isUserOnline($admin, 'admin'));
|
||||
$this->assertTrue($this->presenceService->isUserOnline($user, 'web'));
|
||||
|
||||
// Verify they are different models (Admin vs User)
|
||||
$adminActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_type', get_class($admin))
|
||||
->where('subject_id', $admin->id)
|
||||
->where('properties->guard', 'admin')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$userActivity = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_type', get_class($user))
|
||||
->where('subject_id', $user->id)
|
||||
->where('properties->guard', 'web')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($adminActivity);
|
||||
$this->assertNotNull($userActivity);
|
||||
$this->assertNotEquals($adminActivity->subject_type, $userActivity->subject_type);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_presence_respects_guard_boundaries()
|
||||
{
|
||||
$centralBank = Bank::factory()->create(['level' => 0]);
|
||||
$regularBank = Bank::factory()->create(['level' => 1]);
|
||||
|
||||
// Set both banks online
|
||||
$this->actingAs($centralBank, 'bank');
|
||||
$this->presenceService->updatePresence($centralBank, 'bank');
|
||||
|
||||
$this->actingAs($regularBank, 'bank');
|
||||
$this->presenceService->updatePresence($regularBank, 'bank');
|
||||
|
||||
// Get online banks
|
||||
$onlineBanks = $this->presenceService->getOnlineUsers('bank');
|
||||
|
||||
// Verify both banks are tracked
|
||||
$this->assertEquals(2, $onlineBanks->count());
|
||||
|
||||
// Verify banks don't appear in web guard
|
||||
$onlineWebUsers = $this->presenceService->getOnlineUsers('web');
|
||||
$this->assertEquals(0, $onlineWebUsers->count());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function organization_presence_is_independent_from_users()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$organization->users()->attach($user->id);
|
||||
|
||||
// Set user online on web guard
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Set organization online (different guard, even though same underlying user)
|
||||
$this->actingAs($organization, 'organization');
|
||||
$this->presenceService->updatePresence($organization, 'organization');
|
||||
|
||||
// Verify independent tracking
|
||||
$webOnline = $this->presenceService->getOnlineUsers('web');
|
||||
$orgOnline = $this->presenceService->getOnlineUsers('organization');
|
||||
|
||||
$this->assertEquals(1, $webOnline->count());
|
||||
$this->assertEquals(1, $orgOnline->count());
|
||||
|
||||
// Verify they are different entities
|
||||
$this->assertEquals($user->id, $webOnline->first()['id']);
|
||||
$this->assertEquals($organization->id, $orgOnline->first()['id']);
|
||||
$this->assertNotEquals($webOnline->first()['user_type'], $orgOnline->first()['user_type']);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// LIVEWIRE COMPONENT SECURITY
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function profile_status_badge_cannot_be_exploited_for_idor()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Set both users online
|
||||
$this->actingAs($user1, 'web');
|
||||
$this->presenceService->updatePresence($user1, 'web');
|
||||
|
||||
$this->actingAs($user2, 'web');
|
||||
$this->presenceService->updatePresence($user2, 'web');
|
||||
|
||||
// User1 can check user2's status (presence is intentionally public for time banking)
|
||||
$isUser2Online = $this->presenceService->isUserOnline($user2, 'web');
|
||||
|
||||
// This should return true since user2 is online
|
||||
$this->assertTrue($isUser2Online);
|
||||
|
||||
// But this is NOT an IDOR vulnerability because:
|
||||
// 1. Presence is read-only (cannot manipulate another user's status)
|
||||
// 2. No sensitive data is exposed (only id, name, avatar, last_seen)
|
||||
// 3. This is intentional design for a time banking platform
|
||||
|
||||
// The key security principle: users can only affect their own presence
|
||||
// Even though isUserOnline() allows checking any user's status (intentionally public),
|
||||
// users cannot manipulate other users' presence status
|
||||
|
||||
// Verify the presence data doesn't expose manipulation capabilities
|
||||
$onlineUsers = $this->presenceService->getOnlineUsers('web');
|
||||
$this->assertEquals(2, $onlineUsers->count());
|
||||
|
||||
// Verify both users are listed
|
||||
$userIds = $onlineUsers->pluck('id')->toArray();
|
||||
$this->assertContains($user1->id, $userIds);
|
||||
$this->assertContains($user2->id, $userIds);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function profile_status_badge_does_not_allow_status_manipulation()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$attacker = User::factory()->create();
|
||||
|
||||
// Set user online
|
||||
$this->actingAs($user, 'web');
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
// Authenticate as attacker
|
||||
$this->actingAs($attacker, 'web');
|
||||
|
||||
// Attacker tries to set user offline
|
||||
// (The service only allows setting authenticated user offline)
|
||||
$this->presenceService->setUserOffline($attacker, 'web');
|
||||
|
||||
// Verify user is still online (attacker only affected themselves)
|
||||
$this->assertTrue($this->presenceService->isUserOnline($user, 'web'));
|
||||
$this->assertFalse($this->presenceService->isUserOnline($attacker, 'web'));
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// CLEANUP AND MAINTENANCE
|
||||
// ===========================================
|
||||
|
||||
/** @test */
|
||||
public function presence_cleanup_prevents_database_bloat()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Create multiple presence updates
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
}
|
||||
|
||||
// Check how many records exist
|
||||
$recordCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('causer_id', $user->id)
|
||||
->count();
|
||||
|
||||
// Should be limited by keep_last_presence_updates config
|
||||
$keepCount = timebank_config('presence_settings.keep_last_presence_updates', 5);
|
||||
$this->assertLessThanOrEqual($keepCount, $recordCount);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function offline_status_is_logged_as_activity()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Set online
|
||||
$this->presenceService->updatePresence($user, 'web');
|
||||
|
||||
$onlineActivityCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->count();
|
||||
|
||||
$this->assertEquals(1, $onlineActivityCount, 'Should have 1 online activity');
|
||||
|
||||
// Set offline
|
||||
$this->presenceService->setUserOffline($user, 'web');
|
||||
|
||||
// Check activity count increased (offline logged)
|
||||
$totalActivityCount = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->count();
|
||||
|
||||
$this->assertEquals(2, $totalActivityCount, 'Should have 2 activities (online + offline)');
|
||||
|
||||
// Verify offline status is logged in activity log (even if not used for current online check)
|
||||
$activities = Activity::where('log_name', PresenceService::PRESENCE_ACTIVITY)
|
||||
->where('subject_id', $user->id)
|
||||
->where('subject_type', get_class($user))
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
// Should have both online and offline activities
|
||||
$this->assertCount(2, $activities);
|
||||
|
||||
// Check both activities exist
|
||||
$statuses = $activities->pluck('properties')->pluck('status')->toArray();
|
||||
$this->assertContains('online', $statuses);
|
||||
$this->assertContains('offline', $statuses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\SQL;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Post;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SQL Injection Prevention Tests
|
||||
*
|
||||
* Tests that the application properly sanitizes user input and uses
|
||||
* parameterized queries to prevent SQL injection attacks.
|
||||
*
|
||||
* @group security
|
||||
* @group sql-injection
|
||||
* @group critical
|
||||
*/
|
||||
class SQLInjectionPreventionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test search functionality prevents SQL injection
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function search_prevents_sql_injection()
|
||||
{
|
||||
// Arrange: Create test data
|
||||
$user = User::factory()->create();
|
||||
|
||||
// SQL injection payloads
|
||||
$maliciousQueries = [
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE users--",
|
||||
"' UNION SELECT * FROM users--",
|
||||
"1' OR '1' = '1')) /*",
|
||||
"admin'--",
|
||||
"' OR 1=1--",
|
||||
"' OR 'x'='x",
|
||||
"1; DROP TABLE transactions--",
|
||||
];
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act & Assert: Try each malicious query
|
||||
foreach ($maliciousQueries as $query) {
|
||||
// The search page reads one query parameter itself: the category to browse
|
||||
// (search terms go through the search bar to Elasticsearch, not SQL)
|
||||
$response = $this->get(route('search.show', ['category' => $query]));
|
||||
|
||||
// Should return 200 (search results, even if empty)
|
||||
// Should NOT execute SQL injection
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Verify tables still exist
|
||||
$this->assertTrue(
|
||||
DB::getSchemaBuilder()->hasTable('users'),
|
||||
"SQL injection attempt deleted users table: {$query}"
|
||||
);
|
||||
$this->assertTrue(
|
||||
DB::getSchemaBuilder()->hasTable('transactions'),
|
||||
"SQL injection attempt deleted transactions table: {$query}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test profile name input prevents SQL injection
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function profile_name_input_prevents_sql_injection()
|
||||
{
|
||||
// Arrange: Create user
|
||||
$user = User::factory()->create();
|
||||
|
||||
// SQL injection payloads
|
||||
$maliciousNames = [
|
||||
"admin'; DROP TABLE users--",
|
||||
"test' OR '1'='1",
|
||||
"'; DELETE FROM transactions WHERE '1'='1",
|
||||
];
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act & Assert: Try to update profile with malicious names
|
||||
foreach ($maliciousNames as $name) {
|
||||
// Note: This should be rejected by validation (alphanumeric rule)
|
||||
// But even if validation is bypassed, SQL should be safe
|
||||
|
||||
$response = $this->put(route('user-profile-information.update'), [
|
||||
'name' => $name,
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
// Should either be rejected by validation or safely stored
|
||||
// Tables should still exist
|
||||
$this->assertTrue(
|
||||
DB::getSchemaBuilder()->hasTable('users'),
|
||||
"SQL injection via name field succeeded: {$name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test transaction queries use parameterized statements
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function transaction_queries_use_parameterized_statements()
|
||||
{
|
||||
// Arrange: Create accounts and transaction
|
||||
$user = User::factory()->create();
|
||||
$fromAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
$toAccount = Account::factory()->create([
|
||||
'accountable_type' => User::class,
|
||||
'accountable_id' => $user->id,
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 100,
|
||||
]);
|
||||
|
||||
// SQL injection attempt in description
|
||||
$maliciousDescriptions = [
|
||||
"Payment'; DROP TABLE transactions--",
|
||||
"' OR '1'='1",
|
||||
"Test' UNION SELECT * FROM users--",
|
||||
];
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act: Create transactions with malicious descriptions
|
||||
foreach ($maliciousDescriptions as $description) {
|
||||
try {
|
||||
$transaction = Transaction::create([
|
||||
'from_account_id' => $fromAccount->id,
|
||||
'to_account_id' => $toAccount->id,
|
||||
'amount' => 50,
|
||||
'description' => $description,
|
||||
'transaction_type_id' => 1,
|
||||
]);
|
||||
|
||||
// Description should be stored as-is (string), not executed
|
||||
$this->assertEquals($description, $transaction->description);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// If validation rejects it, that's also acceptable
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// Assert: Table still exists
|
||||
$this->assertTrue(
|
||||
DB::getSchemaBuilder()->hasTable('transactions'),
|
||||
"SQL injection via transaction description succeeded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test WHERE clause uses parameter binding
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function where_clauses_use_parameter_binding()
|
||||
{
|
||||
// Arrange: Create users
|
||||
$user1 = User::factory()->create(['name' => 'testuser1']);
|
||||
$user2 = User::factory()->create(['name' => 'testuser2']);
|
||||
|
||||
// SQL injection attempt in filter
|
||||
$maliciousFilter = "testuser1' OR '1'='1";
|
||||
|
||||
// Act: Query with malicious filter
|
||||
// This simulates a search or filter operation
|
||||
$result = User::where('name', $maliciousFilter)->get();
|
||||
|
||||
// Assert: Should return empty (no user with that exact name)
|
||||
// NOT all users (which would happen if SQL injection succeeded)
|
||||
$this->assertCount(0, $result, "SQL injection in WHERE clause succeeded");
|
||||
|
||||
// Verify both users still exist
|
||||
$this->assertCount(2, User::all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test ORDER BY clause prevents injection
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function order_by_clause_prevents_injection()
|
||||
{
|
||||
// Arrange: Create posts
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Malicious ORDER BY attempts
|
||||
$maliciousOrders = [
|
||||
"created_at; DROP TABLE posts--",
|
||||
"id' OR '1'='1--",
|
||||
"(SELECT * FROM users)",
|
||||
];
|
||||
|
||||
// Act & Assert: Try malicious order parameters
|
||||
foreach ($maliciousOrders as $order) {
|
||||
try {
|
||||
// Attempt to order by malicious input
|
||||
// In real application, this would be through a query parameter
|
||||
$response = $this->get(route('posts.index', ['sort' => $order]));
|
||||
|
||||
// Should either safely handle or reject
|
||||
// Tables should still exist
|
||||
$this->assertTrue(
|
||||
DB::getSchemaBuilder()->hasTable('posts'),
|
||||
"SQL injection via ORDER BY succeeded: {$order}"
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Exception is acceptable if validation rejects it
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test raw queries are properly escaped (if any exist)
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function raw_queries_use_parameter_binding()
|
||||
{
|
||||
// This test verifies that any raw SQL queries in the codebase
|
||||
// use parameter binding instead of string concatenation
|
||||
|
||||
// Arrange: Create test data
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Example of UNSAFE query (should NOT exist in codebase):
|
||||
// DB::select("SELECT * FROM users WHERE name = '" . $input . "'");
|
||||
|
||||
// Example of SAFE query (should be used):
|
||||
// DB::select("SELECT * FROM users WHERE name = ?", [$input]);
|
||||
|
||||
$maliciousInput = "admin' OR '1'='1--";
|
||||
|
||||
// Act: Try query with malicious input
|
||||
$result = DB::select("SELECT * FROM users WHERE name = ?", [$maliciousInput]);
|
||||
|
||||
// Assert: Should return empty (no user with that name)
|
||||
$this->assertEmpty($result, "Raw query parameter binding failed");
|
||||
|
||||
// All users should still exist
|
||||
$this->assertGreaterThan(0, User::count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LIKE queries prevent injection
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function like_queries_prevent_injection()
|
||||
{
|
||||
// Arrange: Create users
|
||||
User::factory()->create(['name' => 'testuser']);
|
||||
User::factory()->create(['name' => 'admin']);
|
||||
|
||||
// Malicious LIKE pattern
|
||||
$maliciousPattern = "%' OR '1'='1";
|
||||
|
||||
// Act: Search with malicious pattern
|
||||
$result = User::where('name', 'LIKE', $maliciousPattern)->get();
|
||||
|
||||
// Assert: Should return empty or safe results
|
||||
// Should NOT return all users
|
||||
$this->assertLessThan(2, $result->count(), "LIKE injection succeeded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test JSON input prevents injection
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function json_input_prevents_injection()
|
||||
{
|
||||
// Arrange: Create user
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Malicious JSON payload
|
||||
$maliciousData = [
|
||||
'name' => "test'; DROP TABLE users--",
|
||||
'email' => "test@example.com",
|
||||
'extra' => "' OR '1'='1",
|
||||
];
|
||||
|
||||
// Act: Submit malicious JSON data
|
||||
$response = $this->putJson(route('user-profile-information.update'), $maliciousData);
|
||||
|
||||
// Assert: Data should be safely handled
|
||||
$this->assertTrue(
|
||||
DB::getSchemaBuilder()->hasTable('users'),
|
||||
"SQL injection via JSON input succeeded"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bulk operations prevent injection
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function bulk_operations_prevent_injection()
|
||||
{
|
||||
// Arrange: Create users
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Malicious IDs array
|
||||
$maliciousIds = [
|
||||
$user1->id,
|
||||
"1' OR '1'='1--",
|
||||
$user2->id,
|
||||
];
|
||||
|
||||
// Act: Try bulk query with malicious IDs
|
||||
try {
|
||||
$result = User::whereIn('id', $maliciousIds)->get();
|
||||
|
||||
// Assert: Should only return valid IDs, not all users
|
||||
$this->assertLessThanOrEqual(2, $result->count(), "Bulk operation injection succeeded");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Exception is acceptable
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// All users should still exist
|
||||
$this->assertCount(2, User::all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileInformationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_current_profile_information_is_available()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$component = Livewire::test(UpdateProfileInformationForm::class);
|
||||
|
||||
$this->assertEquals($user->name, $component->state['name']);
|
||||
$this->assertEquals($user->email, $component->state['email']);
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
Livewire::test(UpdateProfileInformationForm::class)
|
||||
->set('state', ['name' => 'Test Name', 'email' => 'test@example.com'])
|
||||
->call('updateProfileInformation');
|
||||
|
||||
$this->assertEquals('Test Name', $user->fresh()->name);
|
||||
$this->assertEquals('test@example.com', $user->fresh()->email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Stats exposes platform-wide financial data, so entry must be blocked for
|
||||
* anyone without an Admin/Bank profile carrying the stats permission.
|
||||
*/
|
||||
class StatsAccessTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
/** A user that clears the verified / registration-complete middleware. */
|
||||
private function makeUser(string $name): User
|
||||
{
|
||||
$id = DB::table('users')->insertGetId([
|
||||
'name' => $name,
|
||||
'full_name' => $name,
|
||||
'email' => $name . '@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'email_verified_at' => now(),
|
||||
'principles_terms_accepted' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return User::find($id);
|
||||
}
|
||||
|
||||
public function test_guest_is_redirected_to_login(): void
|
||||
{
|
||||
$this->get(route('stats'))->assertRedirect();
|
||||
$this->get(route('stats.pdf'))->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_plain_user_profile_cannot_reach_stats(): void
|
||||
{
|
||||
$user = $this->makeUser('stats_plain_user');
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]);
|
||||
|
||||
$this->assertNotSame(200, $this->get(route('stats'))->getStatusCode());
|
||||
$this->assertNotSame(200, $this->get(route('stats.pdf'))->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_admin_profile_without_the_permission_cannot_reach_stats(): void
|
||||
{
|
||||
$user = $this->makeUser('stats_unprivileged_admin');
|
||||
|
||||
$adminId = DB::table('admins')->insertGetId([
|
||||
'name' => 'stats_admin_profile',
|
||||
'full_name' => 'Stats Admin Profile',
|
||||
'email' => 'stats_admin_profile@example.test',
|
||||
'password' => bcrypt('secret'),
|
||||
'email_verified_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = Admin::find($adminId);
|
||||
$admin->users()->attach($user->id);
|
||||
|
||||
// Authenticated on both guards, but holding no stats permission at all.
|
||||
$this->actingAs($user, 'web');
|
||||
session(['activeProfileType' => Admin::class, 'activeProfileId' => $admin->id]);
|
||||
|
||||
$this->assertNotSame(200, $this->get(route('stats'))->getStatusCode());
|
||||
$this->assertNotSame(200, $this->get(route('stats.pdf'))->getStatusCode());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Light secondary buttons get their own theme colours: 'secondary' is a dark colour in some
|
||||
* themes (vegetable, yellow), which made x-jetstream.secondary-button unreadable there.
|
||||
*/
|
||||
class ThemeButtonColorsTest extends TestCase
|
||||
{
|
||||
private function vars(string $theme): array
|
||||
{
|
||||
config(['themes.active' => $theme]);
|
||||
preg_match_all('/(--[\w-]+): ([^;]+);/', theme_css_vars(), $matches);
|
||||
|
||||
return array_combine($matches[1], $matches[2]);
|
||||
}
|
||||
|
||||
/** Relative luminance (WCAG) of an "r g b" CSS variable value. */
|
||||
private function luminance(string $rgb): float
|
||||
{
|
||||
$channels = array_map(function ($value) {
|
||||
$c = ((int) $value) / 255;
|
||||
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
|
||||
}, explode(' ', trim($rgb)));
|
||||
|
||||
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
|
||||
}
|
||||
|
||||
public function test_every_theme_has_readable_secondary_buttons(): void
|
||||
{
|
||||
foreach (array_keys(config('themes.themes')) as $theme) {
|
||||
$vars = $this->vars($theme);
|
||||
$this->assertArrayHasKey('--color-button-secondary', $vars, $theme);
|
||||
$this->assertArrayHasKey('--color-button-secondary-text', $vars, $theme);
|
||||
|
||||
$light = $this->luminance($vars['--color-button-secondary']);
|
||||
$dark = $this->luminance($vars['--color-button-secondary-text']);
|
||||
$contrast = (max($light, $dark) + 0.05) / (min($light, $dark) + 0.05);
|
||||
$this->assertGreaterThanOrEqual(4.5, $contrast, "$theme: secondary button text contrast is $contrast");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_default_theme_keeps_its_look(): void
|
||||
{
|
||||
$vars = $this->vars('timebank_cc');
|
||||
|
||||
$this->assertSame($vars['--color-secondary'], $vars['--color-button-secondary']);
|
||||
$this->assertSame($vars['--color-text-primary'], $vars['--color-button-secondary-text']);
|
||||
}
|
||||
|
||||
public function test_theme_without_button_colours_falls_back_to_secondary(): void
|
||||
{
|
||||
$custom = config('themes.themes.timebank_cc');
|
||||
unset($custom['colors']['button-secondary'], $custom['colors']['button-secondary-text']);
|
||||
$custom['colors']['secondary'] = '#123456';
|
||||
config(['themes.themes.custom' => $custom]);
|
||||
|
||||
$vars = $this->vars('custom');
|
||||
|
||||
$this->assertSame('18 52 86', $vars['--color-button-secondary']);
|
||||
$this->assertSame($vars['--color-text-primary'], $vars['--color-button-secondary-text']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Jetstream\Http\Livewire\TwoFactorAuthenticationForm;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TwoFactorAuthenticationSettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_two_factor_authentication_can_be_enabled()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$this->withSession(['auth.password_confirmed_at' => time()]);
|
||||
|
||||
Livewire::test(TwoFactorAuthenticationForm::class)
|
||||
->call('enableTwoFactorAuthentication');
|
||||
|
||||
$user = $user->fresh();
|
||||
|
||||
$this->assertNotNull($user->two_factor_secret);
|
||||
$this->assertCount(8, $user->recoveryCodes());
|
||||
}
|
||||
|
||||
public function test_recovery_codes_can_be_regenerated()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$this->withSession(['auth.password_confirmed_at' => time()]);
|
||||
|
||||
$component = Livewire::test(TwoFactorAuthenticationForm::class)
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->call('regenerateRecoveryCodes');
|
||||
|
||||
$user = $user->fresh();
|
||||
|
||||
$component->call('regenerateRecoveryCodes');
|
||||
|
||||
$this->assertCount(8, $user->recoveryCodes());
|
||||
$this->assertCount(8, array_diff($user->recoveryCodes(), $user->fresh()->recoveryCodes()));
|
||||
}
|
||||
|
||||
public function test_two_factor_authentication_can_be_disabled()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$this->withSession(['auth.password_confirmed_at' => time()]);
|
||||
|
||||
$component = Livewire::test(TwoFactorAuthenticationForm::class)
|
||||
->call('enableTwoFactorAuthentication');
|
||||
|
||||
$this->assertNotNull($user->fresh()->two_factor_secret);
|
||||
|
||||
$component->call('disableTwoFactorAuthentication');
|
||||
|
||||
$this->assertNull($user->fresh()->two_factor_secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* php artisan upgrade:verify: a read-only check per area, run before and after every upgrade step
|
||||
* (references/plans/LARAVEL_UPGRADE_PLAN.md, section 4.2). Exit code 1 when a check fails.
|
||||
*/
|
||||
class UpgradeVerifyCommandTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private function makeScopedRole(array $definitionPermissions, array $rolePermissions): string
|
||||
{
|
||||
foreach (array_unique(array_merge($definitionPermissions, $rolePermissions)) as $name) {
|
||||
Permission::findOrCreate($name, 'web');
|
||||
}
|
||||
$definition = AdminRole::create(['name' => 'verify-test-role', 'label' => 'Verify test', 'permissions' => $definitionPermissions]);
|
||||
$role = Role::create(['name' => "Admin\\999\\{$definition->name}", 'guard_name' => 'web']);
|
||||
$role->syncPermissions($rolePermissions);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
return $role->name;
|
||||
}
|
||||
|
||||
public function test_scoped_roles_in_sync_with_their_definition_pass(): void
|
||||
{
|
||||
$this->makeScopedRole(['verify view', 'verify edit'], ['verify edit', 'verify view']);
|
||||
|
||||
$this->artisan('upgrade:verify', ['--only' => 'permissions'])
|
||||
->expectsOutputToContain('scoped roles match admin_roles / organization_roles')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_a_scoped_role_that_differs_from_its_definition_fails(): void
|
||||
{
|
||||
$name = $this->makeScopedRole(['verify view', 'verify edit'], ['verify view']);
|
||||
|
||||
$this->artisan('upgrade:verify', ['--only' => 'permissions'])
|
||||
->expectsOutputToContain($name)
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
public function test_an_app_user_that_may_change_transactions_fails(): void
|
||||
{
|
||||
// The tests connect as MySQL root, which may UPDATE and DELETE everything
|
||||
$this->artisan('upgrade:verify', ['--only' => 'payments'])
|
||||
->expectsOutputToContain('may UPDATE or DELETE transactions')
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
public function test_a_platform_still_named_like_the_template_gets_a_warning(): void
|
||||
{
|
||||
config([config('app.timebank_config') . '.platform_translations.en.platform_name' => 'Example Timebank']);
|
||||
|
||||
$this->artisan('upgrade:verify', ['--only' => 'white-label'])
|
||||
->expectsOutputToContain("platform name is 'Example Timebank'")
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_a_missing_platform_config_fails(): void
|
||||
{
|
||||
config(['app.timebank_config' => 'does-not-exist']);
|
||||
|
||||
$this->artisan('upgrade:verify', ['--only' => 'white-label'])
|
||||
->expectsOutputToContain('config/does-not-exist.php does not exist')
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
public function test_a_platform_folder_config_counts_as_the_platform_config(): void
|
||||
{
|
||||
$root = sys_get_temp_dir() . '/verify-platform-' . uniqid();
|
||||
mkdir($root);
|
||||
file_put_contents("{$root}/config.php", "<?php\n\nreturn ['local_pages' => [], 'android_app' => ['enabled' => false]];\n");
|
||||
config(['app.timebank_config' => 'does-not-exist', 'app.platform_path' => $root]);
|
||||
|
||||
try {
|
||||
$this->artisan('upgrade:verify', ['--only' => 'white-label,platform'])
|
||||
->expectsOutputToContain('platform config: platform/config.php')
|
||||
->expectsOutputToContain('Platform config: platform/config.php')
|
||||
->assertExitCode(0);
|
||||
} finally {
|
||||
unlink("{$root}/config.php");
|
||||
rmdir($root);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_changes_nothing(): void
|
||||
{
|
||||
$before = [Role::count(), Permission::count(), AdminRole::count(), \DB::table('transactions')->count()];
|
||||
|
||||
$this->artisan('upgrade:verify', ['--only' => 'framework,profiles,permissions,white-label,payments']);
|
||||
|
||||
$this->assertSame($before, [Role::count(), Permission::count(), AdminRole::count(), \DB::table('transactions')->count()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
/**
|
||||
* Seed the transaction types when RefreshDatabase builds the test database:
|
||||
* production always has them, the code refers to their ids directly, and
|
||||
* Transaction::booted() refuses transactions with an unknown type.
|
||||
*/
|
||||
protected $seed = true;
|
||||
|
||||
protected $seeder = \Database\Seeders\TransactionTypesTableSeeder::class;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_example()
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user