Laravel Auth

PHP MIT

Laravel Auth by GhostCompiler adds advanced authentication for Laravel with TOTP 2FA, passkeys via WebAuthn, OTP channels (email, SMS, WhatsApp), trusted devices, and tenant-aware social login.

Stars
2
Forks
0
Downloads
N/A
Open Issues
0
Files main

Repository Files

Loading file structure...
tests/Unit/RecoveryCodeServiceTest.php
<?php

declare(strict_types=1);

namespace GhostCompiler\LaravelAuth\Tests\Unit;

use GhostCompiler\LaravelAuth\Models\RecoveryCode;
use GhostCompiler\LaravelAuth\Services\RecoveryCodeService;
use GhostCompiler\LaravelAuth\Tests\Fixtures\User;
use GhostCompiler\LaravelAuth\Tests\TestCase;
use Illuminate\Support\Facades\Hash;

class RecoveryCodeServiceTest extends TestCase
{
    private RecoveryCodeService $service;

    protected function setUp(): void
    {
        parent::setUp();
        $this->service = new RecoveryCodeService;
    }

    public function test_regenerate_returns_the_requested_number_of_codes(): void
    {
        $user = User::query()->create(['email' => 'regen@example.test', 'password' => 'x']);
        $codes = $this->service->regenerate($user, 8);
        self::assertCount(8, $codes);
    }

    public function test_regenerate_returns_custom_count(): void
    {
        $user = User::query()->create(['email' => 'count@example.test', 'password' => 'x']);
        $codes = $this->service->regenerate($user, 3);
        self::assertCount(3, $codes);
    }

    public function test_regenerate_stores_codes_as_hashed_values(): void
    {
        $user = User::query()->create(['email' => 'hash@example.test', 'password' => 'x']);
        $plainCodes = $this->service->regenerate($user, 4);

        $records = RecoveryCode::query()->whereMorphedTo('authenticatable', $user)->get();
        self::assertCount(4, $records);

        foreach ($records as $index => $record) {
            // The stored code is a bcrypt hash — must NOT match the plain code directly
            self::assertNotSame($plainCodes[$index], $record->code);
            // But the hash must verify correctly
            self::assertTrue(Hash::check($plainCodes[$index], $record->code));
        }
    }

    public function test_regenerate_deletes_previous_codes_before_creating_new_ones(): void
    {
        $user = User::query()->create(['email' => 'delete@example.test', 'password' => 'x']);

        $this->service->regenerate($user, 5);
        $this->service->regenerate($user, 3);

        $total = RecoveryCode::query()->whereMorphedTo('authenticatable', $user)->count();
        self::assertSame(3, $total);
    }

    public function test_codes_follow_the_xxxx_xxxx_xxxx_format(): void
    {
        $user = User::query()->create(['email' => 'fmt@example.test', 'password' => 'x']);
        $codes = $this->service->regenerate($user, 4);

        foreach ($codes as $code) {
            self::assertMatchesRegularExpression('/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $code, "Code '{$code}' does not match expected format");
        }
    }

    public function test_consume_returns_true_for_a_valid_unused_code(): void
    {
        $user = User::query()->create(['email' => 'use@example.test', 'password' => 'x']);
        [$code] = $this->service->regenerate($user, 1);

        self::assertTrue($this->service->consume($user, strtoupper((string) $code)));
    }

    public function test_consume_marks_the_code_as_used(): void
    {
        $user = User::query()->create(['email' => 'usedAt@example.test', 'password' => 'x']);
        [$code] = $this->service->regenerate($user, 1);

        $this->service->consume($user, strtoupper((string) $code));

        $record = RecoveryCode::query()->whereMorphedTo('authenticatable', $user)->first();
        self::assertNotNull($record->used_at);
    }

    public function test_consume_returns_false_for_an_already_used_code(): void
    {
        $user = User::query()->create(['email' => 'reuse@example.test', 'password' => 'x']);
        [$code] = $this->service->regenerate($user, 1);

        $this->service->consume($user, strtoupper((string) $code));
        self::assertFalse($this->service->consume($user, strtoupper((string) $code)));
    }

    public function test_consume_returns_false_for_an_invalid_code(): void
    {
        $user = User::query()->create(['email' => 'invalid@example.test', 'password' => 'x']);
        $this->service->regenerate($user, 4);

        self::assertFalse($this->service->consume($user, 'XXXX-XXXX-XXXX'));
    }

    public function test_codes_are_user_scoped_and_do_not_leak_across_users(): void
    {
        $userA = User::query()->create(['email' => 'a@example.test', 'password' => 'x']);
        $userB = User::query()->create(['email' => 'b@example.test', 'password' => 'x']);

        [$codeA] = $this->service->regenerate($userA, 1);
        $this->service->regenerate($userB, 1);

        // User B cannot consume user A's code
        self::assertFalse($this->service->consume($userB, strtoupper((string) $codeA)));
    }
}