Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+22
View File
@@ -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;
}
}
+230
View File
@@ -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());
}
}
+45
View File
@@ -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'));
}
}
+63
View File
@@ -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();
}
}
+102
View File
@@ -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;
});
}
}
+62
View File
@@ -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));
}
}
+123
View File
@@ -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'));
}
}
+479
View File
@@ -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);
}
}
+39
View File
@@ -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'));
}
}
+92
View File
@@ -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',
]);
}
}
+37
View File
@@ -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);
}
}
+75
View File
@@ -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());
}
}
+88
View File
@@ -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]);
}
}
+78
View File
@@ -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'));
}
}
+121
View File
@@ -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'));
}
}
+114
View File
@@ -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);
}
}
+98
View File
@@ -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);
}
}
+120
View File
@@ -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'));
}
}
+59
View File
@@ -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);
}
}
+168
View File
@@ -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"));
}
}
+107
View File
@@ -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 &amp; conditions - ' . e(config('app.name')) . '</title>', $html);
$this->assertStringContainsString('<meta property="og:title" content="Terms &amp; 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;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());
}
}
+129
View File
@@ -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);
}
}
+146
View File
@@ -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());
}
}
+187
View File
@@ -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'));
}
}
+420
View File
@@ -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);
}
}
+269
View File
@@ -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']);
}
}
+118
View File
@@ -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);
}
}
+905
View File
@@ -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());
}
}
+132
View File
@@ -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));
}
}
+521
View File
@@ -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
}
}
+117
View File
@@ -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));
}
}
+260
View File
@@ -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' => ' '], []));
}
}
+317
View File
@@ -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);
}
}
+41
View File
@@ -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();
}
}
+359
View File
@@ -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']);
}
}
+250
View File
@@ -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('&lt;script&gt;', $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('&lt;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('&lt;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('&lt;script&gt;', $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('&lt;a href=&quot;#&quot;', $sanitized['about_en'][0]);
// Verify the dangerous onclick is escaped (quotes are converted to &quot;)
$this->assertStringContainsString('onclick=&quot;', $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('&lt;a', $sanitized['about_en'][0]);
// Verify the script tag within the data URI is also escaped
$this->assertStringContainsString('&lt;script&gt;', $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);
}
}
+77
View File
@@ -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
+66
View File
@@ -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);
}
}
+103
View File
@@ -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()]);
}
}
+19
View File
@@ -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;
}
+18
View File
@@ -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);
}
}