Skip to content

๐Ÿงช Testing Guide

Testing is crucial for maintaining the reliability and security of your authentication system. This guide covers how to test applications using Daycry Auth and how to contribute to the library itself.

๐Ÿ“‹ Table of Contents

๐Ÿƒโ€โ™‚๏ธ Quick Start

Running Tests

# Run all tests
composer test

# Run specific test class
composer test -- --filter="AuthenticationTest"

# Run tests with coverage
composer test:coverage

# Run tests with verbose output
./vendor/bin/phpunit --verbose

Test Environment Setup

The library ships two ready-to-use base classes under tests/_support/:

Class Use when
Tests\Support\TestCase No database needed (unit tests, filter tests)
Tests\Support\DatabaseTestCase Tests that read/write the SQLite in-memory database

Both base classes automatically: - Reset all CI4 services between tests - Inject the array settings handler (so setting() calls work) - Inject a fixed AES-256 encryption key (so service('encrypter') works โ€” needed for TOTP) - Seed with CoreSeeder (groups, permissions, one default user)

<?php

namespace Tests\Authentication;

use Tests\Support\DatabaseTestCase;
use Daycry\Auth\Entities\User;
use Daycry\Auth\Models\UserModel;

class MyAuthTest extends DatabaseTestCase
{
    protected User $user;

    protected function setUp(): void
    {
        parent::setUp();

        // Create a test user via CI4 Fabricator
        $this->user = fake(UserModel::class);
    }
}

๐Ÿงช Test Categories

Unit Tests

  • Authentication Logic: Login, logout, password validation
  • Authorization Logic: Permission checking, group management
  • Models: User operations, data validation
  • Entities: User entity behavior
  • Services: Auth services functionality

Integration Tests

  • Controllers: Full request/response cycles
  • Filters: Request filtering and security
  • Database: Data persistence and retrieval
  • Commands: CLI commands functionality

Feature Tests

  • User Registration: Complete registration flow
  • Login Process: Authentication workflow
  • Password Reset: Reset functionality
  • Access Control: Permission-based access

๐Ÿ”ง Test Setup

Base Test Class

<?php
namespace Tests\Authentication;

use Tests\Support\DatabaseTestCase;
use Daycry\Auth\Entities\User;
use Daycry\Auth\Models\UserModel;

class MySessionTest extends DatabaseTestCase
{
    protected User $user;

    protected function setUp(): void
    {
        parent::setUp();

        // Use CI4 Fabricator to create a user with hashed password + email identity
        $this->user = fake(UserModel::class);
    }

    protected function tearDown(): void
    {
        parent::tearDown();
        auth('session')->logout();
    }
}

Mock Configuration

Use the built-in helpers to override config properties for a single test:

// Override Auth config (authenticators, actions, views, routes, session)
$this->injectMockAttributes(['defaultAuthenticator' => 'jwt']);
$this->injectMockAttributes(['actions' => ['login' => \Daycry\Auth\Authentication\Actions\Totp2FA::class]]);

// Override AuthSecurity config (passwords, lockout, rate-limit, TOTP, token lifetimes)
$this->injectMockAttributesSecurity(['userMaxAttempts' => 3]);
$this->injectMockAttributesSecurity(['minimumPasswordLength' => 12]);

// Override AuthOAuth config (provider definitions)
$this->injectMockAttributesOAuth(['providers' => ['google' => [...]]]);

Each call replaces only the specified keys; unspecified keys keep their defaults.

The previous typo'd names (inkectMockAttributes*) still work as deprecated aliases for backward-compatibility but will be removed in v6 โ€” migrate any custom tests to the spelled-correctly variants.

๐Ÿ›ก๏ธ Testing Authentication

Login Tests

<?php
class SessionAuthenticatorTest extends AuthenticationTestCase
{
    public function testLoginSuccess(): void
    {
        $result = $this->auth->attempt([
            'email'    => 'test@example.com',
            'password' => 'secret123'
        ]);

        $this->assertTrue($result->isOK());
        $this->assertTrue($this->auth->loggedIn());
        $this->assertSame($this->user->id, $this->auth->id());
    }

    public function testLoginWithInvalidCredentials(): void
    {
        $result = $this->auth->attempt([
            'email'    => 'test@example.com',
            'password' => 'wrongpassword'
        ]);

        $this->assertFalse($result->isOK());
        $this->assertFalse($this->auth->loggedIn());
        $this->assertStringContainsString('Invalid credentials', $result->reason());
    }

    public function testLoginWithNonExistentUser(): void
    {
        $result = $this->auth->attempt([
            'email'    => 'nonexistent@example.com',
            'password' => 'secret123'
        ]);

        $this->assertFalse($result->isOK());
        $this->assertFalse($this->auth->loggedIn());
    }
}

Logout Tests

<?php
public function testLogout(): void
{
    // Login first
    $this->auth->attempt([
        'email'    => 'test@example.com',
        'password' => 'secret123'
    ]);

    $this->assertTrue($this->auth->loggedIn());

    // Logout
    $this->auth->logout();

    $this->assertFalse($this->auth->loggedIn());
    $this->assertNull($this->auth->user());
    $this->assertNull($this->auth->id());
}

Remember Me Tests

<?php
public function testRememberMeFunctionality(): void
{
    $result = $this->auth->remember()->attempt([
        'email'    => 'test@example.com',
        'password' => 'secret123'
    ]);

    $this->assertTrue($result->isOK());

    // Check remember token exists
    $user = $this->auth->user();
    $this->assertNotNull($user->remember_token);

    // Simulate new session
    session()->destroy();

    // Should still be logged in via remember token
    $this->assertTrue($this->auth->loggedIn());
}

๐Ÿ‘ฅ Testing Authorization

Permission Tests

<?php
class AuthorizationTest extends DatabaseTestCase
{
    public function testUserHasPermission(): void
    {
        $user = $this->createUserWithPermission('posts.create');

        $this->auth->login($user);

        $this->assertTrue($this->auth->user()->can('posts.create'));
        $this->assertFalse($this->auth->user()->can('posts.delete'));
    }

    public function testGroupPermissions(): void
    {
        $group = $this->createGroup('editors', ['posts.create', 'posts.edit']);
        $user  = $this->createUser();

        $user->addToGroup($group);
        $this->auth->login($user);

        $this->assertTrue($this->auth->user()->inGroup('editors'));
        $this->assertTrue($this->auth->user()->can('posts.create'));
        $this->assertTrue($this->auth->user()->can('posts.edit'));
    }

    private function createUserWithPermission(string $permission): User
    {
        $user = $this->createUser();
        $user->addPermission($permission);

        return $user;
    }
}

๐ŸŽ›๏ธ Testing Controllers

Controller Test Example

<?php
class BaseAuthControllerTest extends DatabaseTestCase
{
    private MockBaseAuthController $controller;
    private IncomingRequest $request;
    private Response $response;

    protected function setUp(): void
    {
        parent::setUp();

        $this->request  = $this->createMockRequest();
        $this->response = new Response(new App());

        $this->controller = new MockBaseAuthController();
        $this->controller->initController($this->request, $this->response, service('logger'));
    }

    public function testControllerInitialization(): void
    {
        $this->assertNotNull($this->controller->publicAuthHandler);
        $this->assertInstanceOf(BaseAuthController::class, $this->controller);
    }

    public function testAjaxResponse(): void
    {
        $this->request->setHeader('X-Requested-With', 'XMLHttpRequest');

        $response = $this->controller->testMethod();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertJson($response->getBody());
    }

    private function createMockRequest(): IncomingRequest
    {
        $userAgent = $this->createMock(UserAgent::class);

        return new IncomingRequest(
            new App(),
            new URI('http://example.com/test'),
            'php://input',
            $userAgent
        );
    }
}

๐Ÿ” Testing Filters

Filter Test Example

<?php
class AuthFilterTest extends DatabaseTestCase
{
    public function testFilterAllowsAuthenticatedUser(): void
    {
        $this->loginAsUser();

        $request  = service('request');
        $response = service('response');

        $filter = new AuthFilter();
        $result = $filter->before($request);

        $this->assertNull($result); // No redirect means allowed
    }

    public function testFilterRedirectsUnauthenticatedUser(): void
    {
        $request  = service('request');
        $response = service('response');

        $filter = new AuthFilter();
        $result = $filter->before($request);

        $this->assertInstanceOf(RedirectResponse::class, $result);
        $this->assertStringContainsString('/login', $result->getHeaderLine('Location'));
    }

    public function testPermissionFilter(): void
    {
        $user = $this->createUserWithPermission('admin.access');
        $this->auth->login($user);

        $filter = new PermissionFilter();
        $result = $filter->before(service('request'), ['admin.access']);

        $this->assertNull($result); // Allowed
    }
}

๐Ÿ“Š Testing Models

User Model Tests

<?php
class UserModelTest extends DatabaseTestCase
{
    public function testCreateUser(): void
    {
        $userData = [
            'username' => 'newuser',
            'email'    => 'new@example.com',
            'password' => 'password123'
        ];

        $userModel = model('UserModel');
        $userId = $userModel->insert($userData);

        $this->assertIsInt($userId);

        $user = $userModel->find($userId);
        $this->assertSame('newuser', $user->username);
        $this->assertSame('new@example.com', $user->email);
    }

    public function testPasswordHashing(): void
    {
        $user = new User([
            'username' => 'testuser',
            'email'    => 'test@example.com',
            'password' => 'plaintext'
        ]);

        $this->assertNotSame('plaintext', $user->password_hash);
        $this->assertTrue(password_verify('plaintext', $user->password_hash));
    }

    public function testEmailValidation(): void
    {
        $userModel = model('UserModel');

        $result = $userModel->insert([
            'username' => 'testuser',
            'email'    => 'invalid-email',
            'password' => 'password123'
        ]);

        $this->assertFalse($result);
        $this->assertArrayHasKey('email', $userModel->errors());
    }
}

๐Ÿ—๏ธ Testing Traits

Testing BaseControllerTrait

<?php
class BaseControllerTraitTest extends DatabaseTestCase
{
    use BaseControllerTrait;

    public function testGetToken(): void
    {
        $token = $this->getToken();

        $this->assertIsArray($token);
        $this->assertArrayHasKey('name', $token);
        $this->assertArrayHasKey('hash', $token);
        $this->assertNotEmpty($token['name']);
        $this->assertNotEmpty($token['hash']);
    }

    public function testSetRequestUnauthorized(): void
    {
        $this->setRequestUnauthorized();

        $this->assertFalse($this->isRequestAuthorized());
    }
}

๐Ÿ”‘ Testing WebAuthn

WebAuthn ceremonies normally require real hardware (a phone, security key, or platform authenticator). To test full register / login / 2FA flows without any device, the library ships a software authenticator under tests/_support/:

Helper Purpose
Tests\Support\WebAuthn\VirtualAuthenticator Produces real attestation / assertion responses (ES256, hand-built CBOR/COSE) that the genuine web-auth/webauthn-lib validators accept. Drives end-to-end ceremonies with no hardware and no brittle static fixtures.
Tests\Support\WebAuthn\SuppressesWebauthnDeprecations A trait WebAuthn tests use to silence the library's own internal deprecations (see note below).
$authn   = new VirtualAuthenticator('example.com', 'https://example.com');
$options = service('webAuthnManager')->startRegistration($user, 'My Laptop');
$entity  = service('webAuthnManager')->finishRegistration($user, $authn->register(json_encode($options)));

Why the deprecation-suppression trait is needed. web-auth/webauthn-lib (v5.3) emits an E_USER_DEPRECATED for the still-required relying-party name. The test suite runs with CODEIGNITER_SCREAM_DEPRECATIONS=1, which turns deprecations into fatals โ€” so WebAuthn tests use SuppressesWebauthnDeprecations to mute the library's own internal deprecations without weakening the global setting.

See WebAuthn / Passkeys โ€” Testing for the security-invariant test layout (tests/WebAuthn/WebAuthnSecurityTest.php).

๐ŸŽฏ Testing Best Practices

1. Test Isolation

<?php
// Each test should be independent
protected function setUp(): void
{
    parent::setUp();
    $this->refreshDatabase();
    $this->createFreshUser();
}

2. Clear Test Names

<?php
// Good: Descriptive test names
public function testUserCannotLoginWithExpiredPassword(): void
public function testAdminCanAccessUserManagement(): void
public function testGuestIsRedirectedToLogin(): void

// Bad: Vague test names
public function testLogin(): void
public function testAccess(): void

3. Test Data Factories

<?php
class UserFactory
{
    public static function create(array $overrides = []): User
    {
        return new User(array_merge([
            'username' => fake()->userName(),
            'email'    => fake()->email(),
            'password' => 'password123',
            'active'   => true,
        ], $overrides));
    }

    public static function createWithPermissions(array $permissions): User
    {
        $user = self::create();
        foreach ($permissions as $permission) {
            $user->addPermission($permission);
        }
        return $user;
    }
}

4. Mock External Dependencies

<?php
public function testEmailNotificationSent(): void
{
    $emailMock = $this->createMock(EmailService::class);
    $emailMock->expects($this->once())
              ->method('send')
              ->with($this->equalTo('user@example.com'));

    Services::injectMock('email', $emailMock);

    // Test code that should trigger email
}

๐Ÿš€ Contributing Tests

Writing New Tests

  1. Follow naming conventions: Use descriptive test method names
  2. Test one thing: Each test should verify one specific behavior
  3. Use assertions properly: Choose the most specific assertion available
  4. Clean up: Ensure tests don't leave side effects

Test Coverage

# Generate coverage report
composer test:coverage

# View coverage in browser
open build/coverage/html/index.html

Pull Request Testing

Before submitting a PR:

# Run full test suite
composer test

# Check code style
composer cs:check

# Run static analysis
composer analyze

# Ensure no deprecation warnings
composer test -- --display-deprecations

Test Examples Repository

For more test examples, check the /tests directory:

  • tests/Authentication/ - Authentication tests
  • tests/Authorization/ - Authorization tests
  • tests/Controllers/ - Controller tests
  • tests/Entities/ - Entity tests
  • tests/Models/ - Model tests (including OAuthTokenRepositoryTest)
  • tests/Libraries/OauthManagerTest.php - OAuth manager tests (events, scopes, profile, refresh)
  • tests/Libraries/Oauth/ProfileResolver/ - Profile resolver tests (factory, Azure, generic)

Remember: Good tests are an investment in your application's reliability and your team's confidence. Write tests that clearly express intent, are easy to maintain, and provide valuable feedback when things break.