Security - v2.0.2
Security Testing Guide
This guide covers testing security features including PHPUnit helpers, test traits, penetration testing support, vulnerability scanning, and CI/CD integration.
Overview
The ArtisanPack Security package provides comprehensive testing support:
- Test Traits: Helpers for testing authentication, authorization, and security features
- Security Assertions: Custom PHPUnit assertions for security testing
- Mock Services: Mock implementations for external security services
- Penetration Testing: Built-in support for security scanning
- CI/CD Integration: Commands for automated security testing
Test Setup
Installing Test Dependencies
composer require --dev artisanpackui/security-testing
Base Test Case
<?php
namespace Tests;
use ArtisanPackUI\Security\Testing\SecurityTestCase;
abstract class TestCase extends SecurityTestCase
{
use CreatesApplication;
}
Or use individual traits:
use ArtisanPackUI\Security\Testing\Traits\InteractsWithAuthentication;
use ArtisanPackUI\Security\Testing\Traits\InteractsWithRoles;
use ArtisanPackUI\Security\Testing\Traits\InteractsWithTwoFactor;
use ArtisanPackUI\Security\Testing\Traits\SecurityAssertions;
class MyTest extends TestCase
{
use InteractsWithAuthentication;
use InteractsWithRoles;
use InteractsWithTwoFactor;
use SecurityAssertions;
}
Authentication Testing
Testing Login
use ArtisanPackUI\Security\Testing\Traits\InteractsWithAuthentication;
class AuthenticationTest extends TestCase
{
use InteractsWithAuthentication;
public function test_user_can_login_with_valid_credentials()
{
$user = User::factory()->create([
'password' => bcrypt('password'),
]);
$response = $this->attemptLogin([
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticated();
}
public function test_login_fails_with_invalid_credentials()
{
$user = User::factory()->create();
$response = $this->attemptLogin([
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_login_is_rate_limited()
{
$user = User::factory()->create();
// Attempt login multiple times
for ($i = 0; $i < 6; $i++) {
$this->attemptLogin([
'email' => $user->email,
'password' => 'wrong-password',
]);
}
$response = $this->attemptLogin([
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertStatus(429);
}
}
Testing Account Lockout
public function test_account_locks_after_failed_attempts()
{
$user = User::factory()->create();
// Configure lockout threshold
config(['artisanpack.security.authentication.lockout.threshold' => 5]);
// Fail 5 login attempts
$this->failLoginAttempts($user, 5);
// Verify account is locked
$this->assertAccountLocked($user);
// Even correct password should fail
$response = $this->attemptLogin([
'email' => $user->email,
'password' => 'password',
]);
$response->assertSessionHasErrors();
}
public function test_account_unlocks_after_timeout()
{
$user = User::factory()->create();
$this->lockAccount($user);
$this->assertAccountLocked($user);
// Travel forward in time
$this->travel(31)->minutes();
$this->assertAccountNotLocked($user);
}
Two-Factor Authentication Testing
Testing 2FA Setup
use ArtisanPackUI\Security\Testing\Traits\InteractsWithTwoFactor;
class TwoFactorTest extends TestCase
{
use InteractsWithTwoFactor;
public function test_user_can_enable_two_factor()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->enableTwoFactor($user);
$response->assertOk();
$this->assertTwoFactorEnabled($user);
$this->assertRecoveryCodesGenerated($user);
}
public function test_two_factor_challenge_required_after_login()
{
$user = User::factory()->create();
$this->enableTwoFactorFor($user);
$response = $this->attemptLogin([
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect('/two-factor/challenge');
$this->assertTwoFactorChallengeRequired();
}
public function test_valid_two_factor_code_authenticates()
{
$user = User::factory()->create();
$this->enableTwoFactorFor($user);
$this->attemptLogin([
'email' => $user->email,
'password' => 'password',
]);
$code = $this->generateValidTwoFactorCode($user);
$response = $this->submitTwoFactorCode($code);
$response->assertRedirect('/dashboard');
$this->assertAuthenticated();
}
public function test_recovery_code_works_when_code_unavailable()
{
$user = User::factory()->create();
$codes = $this->enableTwoFactorFor($user);
$this->attemptLogin([
'email' => $user->email,
'password' => 'password',
]);
$response = $this->submitRecoveryCode($codes[0]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticated();
$this->assertRecoveryCodeUsed($user, $codes[0]);
}
}
Testing TOTP Codes
public function test_expired_totp_code_is_rejected()
{
$user = User::factory()->create();
$this->enableTwoFactorFor($user);
// Generate code from 5 minutes ago
$expiredCode = $this->generateTwoFactorCode($user, now()->subMinutes(5));
$response = $this->submitTwoFactorCode($expiredCode);
$response->assertSessionHasErrors('code');
}
Role and Permission Testing
Testing RBAC
use ArtisanPackUI\Security\Testing\Traits\InteractsWithRoles;
class RoleTest extends TestCase
{
use InteractsWithRoles;
public function test_user_with_permission_can_access_resource()
{
$user = User::factory()->create();
$this->givePermission($user, 'edit-posts');
$this->actingAs($user);
$response = $this->get('/posts/1/edit');
$response->assertOk();
}
public function test_user_without_permission_is_denied()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get('/posts/1/edit');
$response->assertForbidden();
}
public function test_role_grants_permissions()
{
$user = User::factory()->create();
$this->assignRole($user, 'editor');
$this->assertTrue($user->hasPermission('edit-posts'));
$this->assertTrue($user->hasPermission('publish-posts'));
}
public function test_super_admin_has_all_permissions()
{
$user = User::factory()->create();
$this->assignRole($user, 'super-admin');
$this->assertTrue($user->hasPermission('any-permission'));
$this->assertTrue($user->can('anything'));
}
}
Testing Permission Middleware
public function test_permission_middleware_blocks_unauthorized()
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get('/admin/users');
$response->assertForbidden();
}
public function test_permission_middleware_allows_authorized()
{
$user = User::factory()->create();
$this->givePermission($user, 'manage-users');
$this->actingAs($user);
$response = $this->get('/admin/users');
$response->assertOk();
}
API Token Testing
Testing Token Authentication
use ArtisanPackUI\Security\Testing\Traits\InteractsWithApiTokens;
class ApiTokenTest extends TestCase
{
use InteractsWithApiTokens;
public function test_valid_token_authenticates()
{
$user = User::factory()->create();
$token = $this->createTokenFor($user, 'test-token');
$response = $this->withToken($token->plainTextToken)
->getJson('/api/user');
$response->assertOk();
$response->assertJson(['id' => $user->id]);
}
public function test_expired_token_is_rejected()
{
$user = User::factory()->create();
$token = $this->createExpiredTokenFor($user);
$response = $this->withToken($token->plainTextToken)
->getJson('/api/user');
$response->assertUnauthorized();
}
public function test_token_abilities_are_enforced()
{
$user = User::factory()->create();
$token = $this->createTokenFor($user, 'read-only', ['read']);
// Read should work
$response = $this->withToken($token->plainTextToken)
->getJson('/api/posts');
$response->assertOk();
// Write should fail
$response = $this->withToken($token->plainTextToken)
->postJson('/api/posts', ['title' => 'Test']);
$response->assertForbidden();
}
public function test_revoked_token_is_rejected()
{
$user = User::factory()->create();
$token = $this->createTokenFor($user, 'test-token');
$this->revokeToken($token);
$response = $this->withToken($token->plainTextToken)
->getJson('/api/user');
$response->assertUnauthorized();
}
}
Session Security Testing
Testing Session Binding
use ArtisanPackUI\Security\Testing\Traits\InteractsWithSessions;
class SessionSecurityTest extends TestCase
{
use InteractsWithSessions;
public function test_session_is_bound_to_ip()
{
config(['artisanpack.security.advanced_sessions.binding.ip_address.enabled' => true]);
$user = User::factory()->create();
$this->actingAs($user);
// Make request from different IP
$response = $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.100'])
->get('/dashboard');
$response->assertRedirect('/login');
}
public function test_session_rotation_occurs_on_privilege_change()
{
$user = User::factory()->create();
$this->actingAs($user);
$originalSessionId = session()->getId();
// Change role (privilege change)
$user->assignRole('admin');
$this->assertNotEquals($originalSessionId, session()->getId());
}
public function test_concurrent_sessions_are_limited()
{
config(['artisanpack.security.advanced_sessions.concurrent_sessions.max_sessions' => 2]);
$user = User::factory()->create();
// Create 2 sessions
$session1 = $this->createSessionFor($user);
$session2 = $this->createSessionFor($user);
// Third session should terminate oldest
$session3 = $this->createSessionFor($user);
$this->assertSessionTerminated($session1);
$this->assertSessionActive($session2);
$this->assertSessionActive($session3);
}
}
File Upload Security Testing
Testing Upload Validation
use ArtisanPackUI\Security\Testing\Traits\InteractsWithFileUploads;
use Illuminate\Http\UploadedFile;
class FileUploadSecurityTest extends TestCase
{
use InteractsWithFileUploads;
public function test_valid_file_is_accepted()
{
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('photo.jpg');
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertOk();
}
public function test_php_file_is_rejected()
{
$user = User::factory()->create();
$this->actingAs($user);
$file = $this->createMaliciousFile('malware.php', '<?php system($_GET["cmd"]); ?>');
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('file');
}
public function test_double_extension_is_rejected()
{
$user = User::factory()->create();
$this->actingAs($user);
$file = $this->createFileWithDoubleExtension('image.jpg.php');
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
}
public function test_spoofed_mime_type_is_detected()
{
$user = User::factory()->create();
$this->actingAs($user);
// PHP file disguised as image
$file = $this->createSpoofedFile(
filename: 'image.jpg',
actualContent: '<?php echo "malicious"; ?>',
fakeMimeType: 'image/jpeg'
);
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
}
public function test_oversized_file_is_rejected()
{
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->create('large.pdf', 50000); // 50MB
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
}
}
Testing Malware Scanning
public function test_malware_is_detected()
{
$this->mockMalwareScanner();
$user = User::factory()->create();
$this->actingAs($user);
$file = $this->createFileWithEicar(); // EICAR test file
$response = $this->postJson('/api/upload', [
'file' => $file,
]);
$response->assertStatus(422);
$this->assertMalwareDetected();
}
Security Header Testing
Testing CSP
use ArtisanPackUI\Security\Testing\Traits\SecurityAssertions;
class SecurityHeaderTest extends TestCase
{
use SecurityAssertions;
public function test_csp_header_is_present()
{
$response = $this->get('/');
$this->assertHasSecurityHeader($response, 'Content-Security-Policy');
}
public function test_csp_blocks_inline_scripts()
{
$response = $this->get('/');
$this->assertCspBlocksInlineScripts($response);
}
public function test_xss_protection_header_present()
{
$response = $this->get('/');
$this->assertHasSecurityHeader($response, 'X-XSS-Protection', '1; mode=block');
}
public function test_hsts_header_in_production()
{
$this->app['env'] = 'production';
$response = $this->get('/');
$this->assertHasSecurityHeader($response, 'Strict-Transport-Security');
}
public function test_all_security_headers_present()
{
$response = $this->get('/');
$this->assertSecurityHeaders($response, [
'X-Frame-Options' => 'SAMEORIGIN',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
]);
}
}
Custom Security Assertions
Available Assertions
// Authentication
$this->assertAuthenticated();
$this->assertGuest();
$this->assertAuthenticatedAs($user);
$this->assertTwoFactorChallengeRequired();
// Account Status
$this->assertAccountLocked($user);
$this->assertAccountNotLocked($user);
$this->assertPasswordExpired($user);
// Two-Factor
$this->assertTwoFactorEnabled($user);
$this->assertTwoFactorDisabled($user);
$this->assertRecoveryCodesGenerated($user);
$this->assertRecoveryCodeUsed($user, $code);
// Roles and Permissions
$this->assertUserHasRole($user, 'admin');
$this->assertUserHasPermission($user, 'edit-posts');
$this->assertUserDoesNotHavePermission($user, 'delete-posts');
// Sessions
$this->assertSessionActive($session);
$this->assertSessionTerminated($session);
$this->assertSessionCount($user, 2);
// Security Headers
$this->assertHasSecurityHeader($response, 'X-Frame-Options');
$this->assertCspContains($response, 'script-src', "'self'");
// File Security
$this->assertFileQuarantined($file);
$this->assertMalwareDetected();
// API Tokens
$this->assertTokenValid($token);
$this->assertTokenExpired($token);
$this->assertTokenHasAbility($token, 'read');
Mock Services
Mocking External Services
use ArtisanPackUI\Security\Testing\Mocks\MockHaveIBeenPwnedService;
use ArtisanPackUI\Security\Testing\Mocks\MockMalwareScanner;
use ArtisanPackUI\Security\Testing\Mocks\MockGeoIpService;
class SecurityServiceTest extends TestCase
{
public function test_breached_password_is_rejected()
{
// Mock HIBP to always return breached
$this->mockHibp()->alwaysBreached();
$response = $this->post('/register', [
'email' => 'test@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertSessionHasErrors('password');
}
public function test_handles_hibp_service_failure()
{
// Mock HIBP to fail
$this->mockHibp()->alwaysFails();
// Should still allow registration (fail open or fail closed based on config)
$response = $this->post('/register', [
'email' => 'test@example.com',
'password' => 'securePassword123!',
'password_confirmation' => 'securePassword123!',
]);
// Depends on configuration
$response->assertRedirect();
}
protected function mockHibp()
{
return $this->mock(MockHaveIBeenPwnedService::class);
}
}
Mocking Malware Scanner
public function test_clean_file_is_accepted()
{
$this->mockMalwareScanner()->returnsClean();
// Test upload
}
public function test_infected_file_is_quarantined()
{
$this->mockMalwareScanner()->returnsInfected('Trojan.Generic');
// Test upload
}
Penetration Testing Support
Running Security Scans
# Run full security audit
php artisan security:audit
# Run specific checks
php artisan security:audit --check=headers
php artisan security:audit --check=authentication
php artisan security:audit --check=authorization
# Output in different formats
php artisan security:audit --format=json
php artisan security:audit --format=junit
# Save report
php artisan security:audit --output=security-report.html
Vulnerability Scanning
# Scan for vulnerable dependencies
php artisan security:scan-dependencies
# Check for common security misconfigurations
php artisan security:check-config
# Test for SQL injection vulnerabilities
php artisan security:test-injection
# Test for XSS vulnerabilities
php artisan security:test-xss
Security Test Suite
use ArtisanPackUI\Security\Testing\SecurityTestSuite;
class FullSecurityTest extends TestCase
{
public function test_application_security()
{
$suite = new SecurityTestSuite($this->app);
$results = $suite
->testHeaders()
->testAuthentication()
->testAuthorization()
->testInputValidation()
->testFileUploads()
->testApiSecurity()
->run();
$this->assertTrue($results->passed());
}
}
CI/CD Integration
GitHub Actions
# .github/workflows/security.yml
name: Security Tests
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install --no-progress
- name: Run security audit
run: php artisan security:audit --format=junit --output=security-report.xml
- name: Upload security report
uses: actions/upload-artifact@v3
with:
name: security-report
path: security-report.xml
- name: Run security tests
run: php artisan test --testsuite=Security
- name: Check dependencies
run: php artisan security:scan-dependencies
GitLab CI
# .gitlab-ci.yml
security:
stage: test
script:
- composer install --no-progress
- php artisan security:audit --format=junit --output=security-report.xml
- php artisan test --testsuite=Security
- php artisan security:scan-dependencies
artifacts:
reports:
junit: security-report.xml
when: always
Test Data Factories
Security-Related Factories
// Create user with specific security state
$user = User::factory()
->withTwoFactor()
->withRole('admin')
->lockedAccount()
->create();
// Create user with expired password
$user = User::factory()
->passwordExpired()
->create();
// Create user with multiple sessions
$user = User::factory()
->withSessions(3)
->create();
// Create user with API tokens
$user = User::factory()
->withApiToken('read-token', ['read'])
->withApiToken('write-token', ['read', 'write'])
->create();
Best Practices
1. Test Security Features in Isolation
public function test_authentication_lockout()
{
// Test only the lockout feature
$this->withoutMiddleware([RateLimitMiddleware::class]);
// Focus on lockout logic
}
2. Use Realistic Test Data
public function test_password_validation()
{
// Test with realistic weak passwords
$weakPasswords = [
'password',
'123456',
'qwerty',
$user->email, // Email as password
];
foreach ($weakPasswords as $password) {
$this->assertPasswordRejected($password);
}
}
3. Test Edge Cases
public function test_handles_unicode_in_passwords()
{
$password = 'Pässwörd123!';
$user = User::factory()->create([
'password' => bcrypt($password),
]);
$this->attemptLogin([
'email' => $user->email,
'password' => $password,
]);
$this->assertAuthenticated();
}
4. Clean Up Security State
protected function tearDown(): void
{
$this->clearFailedLoginAttempts();
$this->clearRateLimits();
$this->clearSessions();
parent::tearDown();
}