diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..8a06999 --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,45 @@ +validated()); + + Auth::login($user); + + $request->session()->regenerate(); + + return response()->json($user, 201); + } + + public function login(LoginRequest $request): JsonResponse + { + if (! Auth::attempt($request->validated())) { + return response()->json(['message' => 'Invalid credentials.'], 401); + } + + $request->session()->regenerate(); + + return response()->json(Auth::user()); + } + + public function logout(Request $request): JsonResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return response()->json(['message' => 'Logged out.']); + } +} diff --git a/app/Http/Controllers/MovieListController.php b/app/Http/Controllers/MovieListController.php index 58e992c..2ff4864 100644 --- a/app/Http/Controllers/MovieListController.php +++ b/app/Http/Controllers/MovieListController.php @@ -26,7 +26,7 @@ class MovieListController extends Controller $validated = $request->validated(); $movieList = MovieList::create([ ...$validated, - 'owner' => 1, // auth()->id(), + 'owner' => auth()->id(), 'slug' => Str::slug($validated['name']), ]); diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..d614336 --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|string|email', + 'password' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000..8768ffe --- /dev/null +++ b/app/Http/Requests/RegisterRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'username' => 'required|string|max:255|unique:users', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 54f7de4..f070dd1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -19,7 +19,7 @@ class User extends Authenticatable * @var list */ protected $fillable = [ - 'name', + 'username', 'email', 'password', ]; @@ -47,7 +47,8 @@ class User extends Authenticatable ]; } - public function profile(): HasOne { - return $this->hasOne(Profile::class); + public function profile(): HasOne + { + return $this->hasOne(Profile::class); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index ab17935..9676c94 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->statefulApi(); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->renderable(function (MovieNotFoundException $e, Request $request) { diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..08aa7f1 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => explode(',', env('ALLOWED_ORIGINS', '')), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/routes/api.php b/routes/api.php index 5701fba..6867f07 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,18 +1,26 @@ user(); -})->middleware('auth:sanctum'); +// Public auth routes +Route::post('/register', [AuthController::class, 'register']); +Route::post('/login', [AuthController::class, 'login']); -// MOVIES -Route::get('/movies/search', [MovieController::class, 'search'])->name('movies.search'); +// Authenticated routes +Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/user', fn (Request $request) => $request->user()); -// MOVIE LISTS -Route::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show'); -Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store'); -Route::delete('/movielists/{movieList}', [MovieListController::class, 'destroy'])->name('movielists.destroy'); + // Movies + Route::get('/movies/search', [MovieController::class, 'search'])->name('movies.search'); + + // Movie Lists + Route::get('/movielists', [MovieListController::class, 'index'])->name('movielists.index'); + Route::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show'); + Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store'); + Route::delete('/movielists/{movieList}', [MovieListController::class, 'destroy'])->name('movielists.destroy'); +}); diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php new file mode 100644 index 0000000..34b95ea --- /dev/null +++ b/tests/Feature/AuthTest.php @@ -0,0 +1,128 @@ +withHeader('Origin', 'http://localhost') + ->postJson('/api/register', [ + 'username' => 'johndoe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertStatus(201) + ->assertJsonFragment(['username' => 'johndoe', 'email' => 'john@example.com']); + + $this->assertDatabaseHas('users', ['email' => 'john@example.com', 'username' => 'johndoe']); + } + + public function test_registration_fails_with_invalid_data(): void + { + $response = $this->postJson('/api/register', [ + 'username' => '', + 'email' => 'not-an-email', + 'password' => 'short', + 'password_confirmation' => 'mismatch', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['username', 'email', 'password']); + } + + public function test_registration_fails_with_duplicate_email(): void + { + User::factory()->create(['email' => 'john@example.com']); + + $response = $this->postJson('/api/register', [ + 'username' => 'johndoe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_registration_fails_with_duplicate_username(): void + { + User::factory()->create(['username' => 'johndoe']); + + $response = $this->postJson('/api/register', [ + 'username' => 'johndoe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['username']); + } + + public function test_user_can_login_with_correct_credentials(): void + { + User::factory()->create(['email' => 'john@example.com']); + + $response = $this->withHeader('Origin', 'http://localhost') + ->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'password', + ]); + + $response->assertOk() + ->assertJsonFragment(['email' => 'john@example.com']); + } + + public function test_login_fails_with_wrong_credentials(): void + { + User::factory()->create(['email' => 'john@example.com']); + + $response = $this->withHeader('Origin', 'http://localhost') + ->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['message' => 'Invalid credentials.']); + } + + public function test_authenticated_user_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withHeader('Origin', 'http://localhost') + ->postJson('/api/logout'); + + $response->assertOk() + ->assertJsonFragment(['message' => 'Logged out.']); + } + + public function test_unauthenticated_user_cannot_access_protected_routes(): void + { + $response = $this->getJson('/api/user'); + + $response->assertStatus(401); + } + + public function test_authenticated_user_can_access_user_endpoint(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->getJson('/api/user'); + + $response->assertOk() + ->assertJsonFragment(['email' => $user->email]); + } +}