Skip to content

Commit 3e4a4f5

Browse files
authored
Feature: Adds test for login, logout, forgot-password and reset-password (#544)
1 parent f4adca9 commit 3e4a4f5

File tree

11 files changed

+561
-9
lines changed

11 files changed

+561
-9
lines changed

backend/app/Http/Actions/Accounts/CreateAccountAction.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use HiEvents\Services\Application\Handlers\Auth\DTO\LoginCredentialsDTO;
1818
use HiEvents\Services\Application\Handlers\Auth\LoginHandler;
1919
use HiEvents\Services\Application\Locale\LocaleService;
20+
use Illuminate\Contracts\Encryption\DecryptException;
2021
use Illuminate\Http\JsonResponse;
2122
use Illuminate\Validation\ValidationException;
2223
use Throwable;
@@ -54,6 +55,10 @@ public function __invoke(CreateAccountRequest $request): JsonResponse
5455
throw ValidationException::withMessages([
5556
'email' => $e->getMessage(),
5657
]);
58+
} catch (DecryptException $e) {
59+
throw ValidationException::withMessages([
60+
'invite_token' => __('Invalid invite token'),
61+
]);
5762
} catch (AccountRegistrationDisabledException) {
5863
return $this->errorResponse(
5964
message: __('Account registration is disabled'),

backend/app/Http/Actions/Auth/ResetPasswordAction.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace HiEvents\Http\Actions\Auth;
44

5+
use HiEvents\Exceptions\InvalidPasswordResetTokenException;
56
use HiEvents\Exceptions\PasswordInvalidException;
67
use HiEvents\Http\Actions\BaseAction;
78
use HiEvents\Http\Request\Auth\ResetPasswordRequest;
@@ -37,6 +38,8 @@ public function __invoke(ResetPasswordRequest $request): JsonResponse
3738
throw ValidationException::withMessages([
3839
'current_password' => $exception->getMessage(),
3940
]);
41+
} catch (InvalidPasswordResetTokenException $e) {
42+
throw new ResourceNotFoundException($e->getMessage());
4043
}
4144

4245
return $this->jsonResponse(

backend/app/Models/Account.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
namespace HiEvents\Models;
66

77
use HiEvents\DomainObjects\Enums\Role;
8+
use Illuminate\Database\Eloquent\Factories\HasFactory;
89
use Illuminate\Database\Eloquent\Relations\BelongsTo;
910
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
1011
use Illuminate\Database\Eloquent\SoftDeletes;
1112

1213
class Account extends BaseModel
1314
{
1415
use SoftDeletes;
16+
use HasFactory;
1517

1618
public function users(): BelongsToMany
1719
{

backend/app/Models/User.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
1111
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
1212
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
13+
use Illuminate\Database\Eloquent\Factories\HasFactory;
1314
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
1415
use Illuminate\Database\Eloquent\Relations\HasOne;
1516
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
@@ -27,6 +28,7 @@ class User extends BaseModel implements AuthenticatableContract, AuthorizableCon
2728
use Authorizable;
2829
use CanResetPassword;
2930
use MustVerifyEmail;
31+
use HasFactory;
3032

3133
/** @var array */
3234
protected $guarded = [];

backend/app/Services/Application/Handlers/Auth/ForgotPasswordHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
use HiEvents\Repository\Interfaces\UserRepositoryInterface;
99
use HiEvents\Services\Infrastructure\TokenGenerator\TokenGeneratorService;
1010
use Illuminate\Database\DatabaseManager;
11-
use Illuminate\Mail\Mailer;
11+
use Illuminate\Contracts\Mail\Mailer;
1212
use Psr\Log\LoggerInterface;
1313
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
1414
use Throwable;

backend/app/Services/Application/Handlers/Auth/ResetPasswordHandler.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
namespace HiEvents\Services\Application\Handlers\Auth;
44

55
use HiEvents\DomainObjects\UserDomainObject;
6+
use HiEvents\Exceptions\PasswordInvalidException;
67
use HiEvents\Mail\User\ResetPasswordSuccess;
78
use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface;
89
use HiEvents\Repository\Interfaces\UserRepositoryInterface;
910
use HiEvents\Services\Application\Handlers\Auth\DTO\ResetPasswordDTO;
1011
use HiEvents\Services\Domain\Auth\ResetPasswordTokenValidateService;
12+
use Illuminate\Contracts\Mail\Mailer;
1113
use Illuminate\Database\DatabaseManager;
1214
use Illuminate\Hashing\HashManager;
13-
use Illuminate\Mail\Mailer;
1415
use Psr\Log\LoggerInterface;
1516
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
1617
use Throwable;
@@ -38,13 +39,22 @@ public function handle(ResetPasswordDTO $resetPasswordData): void
3839
$resetToken = $this->passwordTokenValidateService->validateAndFetchToken($resetPasswordData->token);
3940
$user = $this->validateUser($resetToken->getEmail());
4041

42+
if ($this->checkNewPasswordIsOldPassword($user, $resetPasswordData->password)) {
43+
throw new PasswordInvalidException(__('New password must be different from the old password.'));
44+
}
45+
4146
$this->resetUserPassword($user->getId(), $resetPasswordData->password);
4247
$this->deleteResetToken($resetToken->getEmail());
4348
$this->logResetPasswordSuccess($user);
4449
$this->sendResetPasswordEmail($user);
4550
});
4651
}
4752

53+
private function checkNewPasswordIsOldPassword(UserDomainObject $user, string $newPassword): bool
54+
{
55+
return $this->hashManager->check($newPassword, $user->getPassword());
56+
}
57+
4858
private function validateUser(string $email): UserDomainObject
4959
{
5060
$user = $this->userRepository->findFirstWhere(['email' => $email]);
@@ -72,7 +82,7 @@ private function deleteResetToken(string $email): void
7282
$this->passwordResetTokenRepository->deleteWhere(['email' => $email]);
7383
}
7484

75-
private function logResetPasswordSuccess($user): void
85+
private function logResetPasswordSuccess(UserDomainObject $user): void
7686
{
7787
$this->logger->info('Password reset successfully', [
7888
'user_id' => $user->getId(),
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Database\Factories;
6+
7+
use HiEvents\Helper\IdHelper;
8+
use Illuminate\Database\Eloquent\Factories\Factory;
9+
10+
/**
11+
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\HiEvents\Models\Account>
12+
*/
13+
class AccountFactory extends Factory
14+
{
15+
/**
16+
* Define the model's default state.
17+
*
18+
* @return array<string, mixed>
19+
*/
20+
public function definition(): array
21+
{
22+
$currencies = include base_path('data/currencies.php');
23+
24+
return [
25+
'name' => fake()->name(),
26+
'email' => fake()->unique()->safeEmail(),
27+
'timezone' => fake()->timezone(),
28+
'currency_code' => fake()->randomElement(array_values($currencies)),
29+
'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX),
30+
'account_configuration_id' => 1, // Default account configuration is first entry
31+
];
32+
}
33+
34+
/**
35+
* Indicate that the model's stripe account id is set.
36+
*/
37+
public function stripeAccount(): self
38+
{
39+
return $this->state(fn(array $attributes) => [
40+
'stripe_account_id' => fake()->stripeConnectAccountId(),
41+
]);
42+
}
43+
44+
/**
45+
* Indicate that the model's stripe account connection setup is complete.
46+
*/
47+
public function stripeConnectSetupComplete(bool $isComplete = true): self
48+
{
49+
return $this->state(fn(array $attributes) => [
50+
'stripe_connect_setup_complete' => $isComplete,
51+
]);
52+
}
53+
54+
/**
55+
* Indicate that the model is verified.
56+
*/
57+
public function verified(): self
58+
{
59+
return $this->state(fn(array $attributes) => [
60+
'account_verified_at' => now(),
61+
]);
62+
}
63+
64+
/**
65+
* Indicate that the model has been manually verified.
66+
*/
67+
public function manuallyVerified(): self
68+
{
69+
return $this->state(fn(array $attributes) => [
70+
'is_manually_verified' => true,
71+
]);
72+
}
73+
}

backend/database/factories/UserFactory.php

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
x<?php
1+
<?php
2+
3+
declare(strict_types=1);
24

35
namespace Database\Factories;
46

7+
use HiEvents\DomainObjects\Enums\Role;
8+
use HiEvents\DomainObjects\Status\UserStatus;
9+
use HiEvents\Locale;
10+
use HiEvents\Models\Account;
11+
use HiEvents\Models\User;
512
use Illuminate\Database\Eloquent\Factories\Factory;
6-
use Illuminate\Support\Str;
13+
use Illuminate\Support\Facades\Hash;
714

815
/**
916
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\HiEvents\Core\Models\User>
@@ -18,21 +25,59 @@ class UserFactory extends Factory
1825
public function definition(): array
1926
{
2027
return [
21-
'name' => fake()->name(),
28+
'first_name' => fake()->firstName(),
29+
'last_name' => fake()->lastName(),
2230
'email' => fake()->unique()->safeEmail(),
2331
'email_verified_at' => now(),
24-
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
25-
'remember_token' => Str::random(10),
32+
'password' => Hash::make(fake()->password(16)),
33+
'timezone' => fake()->timezone(),
34+
'locale' => fake()->randomElement(Locale::getSupportedLocales()),
2635
];
2736
}
2837

38+
public function pendingEmail(?string $email = null): self
39+
{
40+
return $this->state(fn(array $attributes) => [
41+
'pending_email' => $email ?? fake()->unique()->safeEmail(),
42+
]);
43+
}
44+
45+
/**
46+
* Set the user's password.
47+
*/
48+
public function password(string $password): static
49+
{
50+
return $this->state(fn(array $attributes) => [
51+
'password' => Hash::make($password),
52+
]);
53+
}
54+
2955
/**
3056
* Indicate that the model's email address should be unverified.
3157
*/
3258
public function unverified(): static
3359
{
34-
return $this->state(fn (array $attributes) => [
60+
return $this->state(fn(array $attributes) => [
3561
'email_verified_at' => null,
3662
]);
3763
}
64+
65+
/**
66+
* Saves an Account to the database and attaches it to the user.
67+
*/
68+
public function withAccount(): static
69+
{
70+
return $this->afterCreating(function (User $user): void {
71+
$account = Account::factory()->verified()->create();
72+
$account->timezone = $user->timezone;
73+
$account->name = $user->first_name . ($user->last_name ? ' ' . $user->last_name : '');
74+
$account->email = strtolower($user->email);
75+
76+
$user->accounts()->attach($account, [
77+
'role' => Role::ADMIN,
78+
'status' => UserStatus::ACTIVE,
79+
'is_account_owner' => true,
80+
]);
81+
});
82+
}
3883
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature;
6+
7+
use Illuminate\Foundation\Testing\DatabaseTruncation;
8+
use HiEvents\Models\AccountConfiguration;
9+
use HiEvents\Models\User;
10+
use Tests\TestCase;
11+
12+
class LoginTest extends TestCase
13+
{
14+
use DatabaseTruncation;
15+
16+
private const API_PREFIX = '';
17+
private const LOGIN_ROUTE = self::API_PREFIX . '/auth/login';
18+
private const LOGOUT_ROUTE = self::API_PREFIX . '/auth/logout';
19+
private const USERS_ME_ROUTE = self::API_PREFIX . '/users/me';
20+
21+
public function setUp(): void
22+
{
23+
parent::setUp();
24+
AccountConfiguration::firstOrCreate(['id' => 1], [
25+
'id' => 1,
26+
'name' => 'Default',
27+
'is_system_default' => true,
28+
'application_fees' => json_encode(['percentage' => 1.5, 'fixed' => 0]),
29+
]);
30+
}
31+
32+
public function test_login_with_valid_credentials(): void
33+
{
34+
$password = fake()->password(16);
35+
$user = User::factory()->password($password)->withAccount()->create();
36+
37+
$response = $this->postJson(self::LOGIN_ROUTE, [
38+
'email' => $user->email,
39+
'password' => $password,
40+
]);
41+
42+
$response->assertCookie('token');
43+
$response->assertHeader('X-Auth-Token');
44+
$response->assertJsonStructure([
45+
'token',
46+
'token_type',
47+
'expires_in',
48+
'user',
49+
'accounts'
50+
]);
51+
52+
// removes warning "This test did not perform any assertions"
53+
$this->assertTrue(true);
54+
}
55+
56+
public function test_login_with_invalid_credentials(): void
57+
{
58+
$password = fake()->password(16);
59+
$user = User::factory()->password($password)->withAccount()->create();
60+
61+
$response = $this->postJson(self::LOGIN_ROUTE, [
62+
'email' => $user->email,
63+
'password' => 'invalid_password',
64+
]);
65+
66+
$response->assertStatus(401);
67+
$response->assertCookieMissing('token');
68+
$response->assertHeaderMissing('X-Auth-Token');
69+
}
70+
71+
72+
public function test_logout(): void
73+
{
74+
$password = fake()->password(16);
75+
$user = User::factory()->password($password)->withAccount()->create();
76+
77+
$response = $this->postJson(self::LOGIN_ROUTE, [
78+
'email' => $user->email,
79+
'password' => $password,
80+
]);
81+
$response->assertCookie('token');
82+
83+
$response2 = $this->postJson(self::LOGOUT_ROUTE, [], [
84+
'Authorization' => 'Bearer ' . $response->headers->get('X-Auth-Token'),
85+
]);
86+
$response2->assertStatus(200);
87+
$response2->assertCookieExpired('token');
88+
89+
// try to use the expired token
90+
$response3 = $this->getJson(self::USERS_ME_ROUTE, [
91+
'Authorization' => 'Bearer ' . $response->headers->get('X-Auth-Token'),
92+
]);
93+
$response3->assertStatus(401);
94+
}
95+
}

0 commit comments

Comments
 (0)