Files
headroom/backend/tests/Feature/Auth/AuthenticationTest.php
Santhosh Janardhanan 47068dabce feat(api): Implement API Resource Standard compliance
- Create BaseResource with formatDate() and formatDecimal() utilities
- Create 11 API Resource classes for all models
- Update all 6 controllers to return wrapped responses via wrapResource()
- Update frontend API client with unwrapResponse() helper
- Update all 63+ backend tests to expect 'data' wrapper
- Regenerate Scribe API documentation

BREAKING CHANGE: All API responses now wrap data in 'data' key per architecture spec.

Backend Tests: 70 passed, 5 failed (unrelated to data wrapper)
Frontend Unit: 10 passed
E2E Tests: 102 passed, 20 skipped
API Docs: Generated successfully

Refs: openspec/changes/api-resource-standard
2026-02-19 14:51:56 -05:00

403 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
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;
}
[$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',
'data' => [
'id',
'name',
'email',
'role',
'active',
'created_at',
'updated_at',
],
]);
$response->assertJsonPath('data.name', $user->name);
$response->assertJsonPath('data.email', $user->email);
$response->assertJsonPath('data.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([
'data' => [
'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 = Cache::has("refresh_token:{$oldRefreshToken}");
$this->assertFalse($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 = Cache::has("refresh_token:{$refreshToken}");
$this->assertFalse($tokenExists, 'Refresh token should be removed from cache');
}
/** @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 = Cache::get("refresh_token:{$refreshToken}");
$this->assertEquals($user->id, $storedUserId);
// Verify token exists in cache (TTL verification skipped for array driver)
$this->assertTrue(Cache::has("refresh_token:{$refreshToken}"), 'Refresh token should exist in cache');
}
/** @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);
}
}