feat: Reinitialize frontend with SvelteKit and TypeScript
- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
This commit is contained in:
398
backend/tests/Feature/Auth/AuthenticationTest.php
Normal file
398
backend/tests/Feature/Auth/AuthenticationTest.php
Normal file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Redis::flushall();
|
||||
}
|
||||
|
||||
protected function loginAndGetTokens($user)
|
||||
{
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
protected function generateExpiredToken($user)
|
||||
{
|
||||
$payload = [
|
||||
'iss' => config('app.url', 'headroom'),
|
||||
'sub' => $user->id,
|
||||
'iat' => time() - 7200,
|
||||
'exp' => time() - 3600,
|
||||
'role' => $user->role,
|
||||
'permissions' => [],
|
||||
'jti' => uniqid('token_', true),
|
||||
];
|
||||
|
||||
return $this->encodeJWT($payload);
|
||||
}
|
||||
|
||||
protected function encodeJWT(array $payload): string
|
||||
{
|
||||
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
|
||||
$header = base64_encode($header);
|
||||
$header = str_replace(['+', '/', '='], ['-', '_', ''], $header);
|
||||
|
||||
$payload = json_encode($payload);
|
||||
$payload = base64_encode($payload);
|
||||
$payload = str_replace(['+', '/', '='], ['-', '_', ''], $payload);
|
||||
|
||||
$signature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
|
||||
$signature = base64_encode($signature);
|
||||
$signature = str_replace(['+', '/', '='], ['-', '_', ''], $signature);
|
||||
|
||||
return $header . '.' . $payload . '.' . $signature;
|
||||
}
|
||||
|
||||
protected function decodeJWT(string $token): ?object
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
list($header, $payload, $signature) = $parts;
|
||||
|
||||
$expectedSignature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
|
||||
$expectedSignature = base64_encode($expectedSignature);
|
||||
$expectedSignature = str_replace(['+', '/', '='], ['-', '_', ''], $expectedSignature);
|
||||
|
||||
if (! hash_equals($expectedSignature, $signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = base64_decode(str_replace(['-', '_'], ['+', '/'], $payload));
|
||||
|
||||
return json_decode($payload);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_can_login_with_valid_credentials()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'john@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'john@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token_type',
|
||||
'expires_in',
|
||||
'user' => [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
],
|
||||
]);
|
||||
$response->assertJsonPath('user.name', $user->name);
|
||||
$response->assertJsonPath('user.email', $user->email);
|
||||
$response->assertJsonPath('user.role', 'manager');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function login_fails_with_invalid_credentials()
|
||||
{
|
||||
User::factory()->create([
|
||||
'email' => 'john@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'john@example.com',
|
||||
'password' => 'wrongpassword',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid credentials',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function login_fails_when_account_is_inactive()
|
||||
{
|
||||
User::factory()->create([
|
||||
'email' => 'inactive@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'inactive@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJson([
|
||||
'message' => 'Account is inactive',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function login_validates_required_fields()
|
||||
{
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => '',
|
||||
'password' => '',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['email', 'password']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function login_validates_email_format()
|
||||
{
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'not-an-email',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['email']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function authenticated_api_request_succeeds_with_valid_token()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$tokens['access_token']}")
|
||||
->getJson('/api/user');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function api_request_fails_without_token()
|
||||
{
|
||||
$response = $this->getJson('/api/user');
|
||||
|
||||
$response->assertStatus(401);
|
||||
$response->assertJson([
|
||||
'message' => 'Authentication required',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function api_request_fails_with_expired_token()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$expiredToken = $this->generateExpiredToken($user);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$expiredToken}")
|
||||
->getJson('/api/user');
|
||||
|
||||
$response->assertStatus(401);
|
||||
$response->assertJson([
|
||||
'message' => 'Token expired',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function api_request_fails_with_invalid_token()
|
||||
{
|
||||
$response = $this->withHeader('Authorization', 'Bearer invalid.token.here')
|
||||
->getJson('/api/user');
|
||||
|
||||
$response->assertStatus(401);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid token',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_can_refresh_access_token()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
$oldRefreshToken = $tokens['refresh_token'];
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$tokens['access_token']}")
|
||||
->postJson('/api/auth/refresh', [
|
||||
'refresh_token' => $oldRefreshToken,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token_type',
|
||||
'expires_in',
|
||||
]);
|
||||
|
||||
$oldTokenExists = Redis::exists("refresh_token:{$user->id}:{$oldRefreshToken}");
|
||||
$this->assertEquals(0, $oldTokenExists, 'Old refresh token should be invalidated');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function refresh_fails_with_invalid_refresh_token()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$tokens['access_token']}")
|
||||
->postJson('/api/auth/refresh', [
|
||||
'refresh_token' => 'invalid_refresh_token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid or expired refresh token',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_can_logout()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
$refreshToken = $tokens['refresh_token'];
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$tokens['access_token']}")
|
||||
->postJson('/api/auth/logout', [
|
||||
'refresh_token' => $refreshToken,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'message' => 'Logged out successfully',
|
||||
]);
|
||||
|
||||
$tokenExists = Redis::exists("refresh_token:{$user->id}:{$refreshToken}");
|
||||
$this->assertEquals(0, $tokenExists, 'Refresh token should be removed from Redis');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function token_contains_required_claims()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
$token = $tokens['access_token'];
|
||||
$payload = $this->decodeJWT($token);
|
||||
|
||||
$this->assertNotNull($payload);
|
||||
$this->assertEquals($user->id, $payload->sub);
|
||||
$this->assertEquals('manager', $payload->role);
|
||||
$this->assertIsArray($payload->permissions);
|
||||
$this->assertContains('manage_projects', $payload->permissions);
|
||||
$this->assertNotNull($payload->iat);
|
||||
$this->assertNotNull($payload->exp);
|
||||
$this->assertNotNull($payload->jti);
|
||||
|
||||
$expectedExp = $payload->iat + (60 * 60);
|
||||
$this->assertEquals($expectedExp, $payload->exp, 'Token should expire in 60 minutes');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function refresh_token_is_stored_in_redis()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
$refreshToken = $tokens['refresh_token'];
|
||||
|
||||
$storedUserId = Redis::get("refresh_token:{$refreshToken}");
|
||||
$this->assertEquals($user->id, $storedUserId);
|
||||
|
||||
$ttl = Redis::ttl("refresh_token:{$refreshToken}");
|
||||
$this->assertGreaterThan(604700, $ttl);
|
||||
$this->assertLessThanOrEqual(604800, $ttl);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function subsequent_requests_fail_after_logout()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tokens = $this->loginAndGetTokens($user);
|
||||
$accessToken = $tokens['access_token'];
|
||||
$refreshToken = $tokens['refresh_token'];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$accessToken}")
|
||||
->postJson('/api/auth/logout', [
|
||||
'refresh_token' => $refreshToken,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$accessToken}")
|
||||
->getJson('/api/user');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
19
backend/tests/Feature/ExampleTest.php
Normal file
19
backend/tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
10
backend/tests/TestCase.php
Normal file
10
backend/tests/TestCase.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
16
backend/tests/Unit/ExampleTest.php
Normal file
16
backend/tests/Unit/ExampleTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user