Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
API_PORT=8000
WEB_PORT=3000
REDIS_PORT=6379
54 changes: 29 additions & 25 deletions .github/workflows/laravel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,38 +20,42 @@ on:
- 'apps/api/tests/**'
- 'apps/api/routes/**'

jobs:
laravel-tests:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
pest:
name: Pest
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/api

steps:
- uses: pustovitDmytro/redis-github-action@v1.0.2
with:
redis-version: '7.2'
- uses: shivammathur/setup-php@15c43e89cdef867065b0213be354c2841860869e
- uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- uses: actions/checkout@v3
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Generate key
coverage: none
tools: composer:v2

- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: apps/api/vendor
key: ${{ runner.os }}-php-${{ hashFiles('apps/api/composer.lock') }}
restore-keys: ${{ runner.os }}-php-

- name: Install Composer dependencies
run: composer install --no-interaction --no-progress --prefer-dist --no-scripts

- name: Prepare environment file
run: cp .env.example .env

- name: Generate application key
run: php artisan key:generate
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Create Database
run: |
mkdir -p database
touch database/database.sqlite
- name: Execute tests (Unit and Feature tests) via PHPUnit
env:
DB_CONNECTION: sqlite
DB_DATABASE: database/database.sqlite
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
run: vendor/bin/phpunit

- name: Run Pest
run: ./vendor/bin/pest --no-coverage
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The goal of the project is to create a template for development on Laravel and N
- [**Laravel Socialite**](https://laravel.com/docs/13.x/socialite) OAuth providers
- [**Laravel Sail**](https://laravel.com/docs/13.x/sail) Light-weight command-line interface for interacting with Laravel's default Docker development environment.
- [**Spatie Laravel Permissions**](https://spatie.be/docs/laravel-permission/v6/introduction) This package allows you to manage user permissions and roles in a database.
- [**Pest**](https://pestphp.com/) Elegant testing framework with expressive syntax and zero-config Laravel integration.
- UI library [**Nuxt UI 4**](https://ui.nuxt.com/) based on [**TailwindCSS 4**](https://tailwindcss.com/) and [**Reka UI**](https://reka-ui.com/).
- [**Pinia**](https://pinia.vuejs.org/ssr/nuxt.html) The intuitive store for Vue.js
- Integrated pages: login, registration, password recovery, email confirmation, account information update, password change.
Expand Down Expand Up @@ -109,7 +110,7 @@ just sail ... # invoke Laravel Sail directly (e.g. `just sail tinke
just composer require ...
just bun add @vueuse/core
just pint # PHP linter
just test # PHPUnit
just test # Pest
just down -v # stop and remove volumes
```

Expand Down
12 changes: 6 additions & 6 deletions apps/api/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,18 @@
"license": "MIT",
"require": {
"php": "^8.4",
"intervention/image": "^3.4",
"intervention/image": "^4.0",
"laravel/framework": "^13.0",
"laravel/octane": "^2.16",
"laravel/sanctum": "^4.0",
"laravel/socialite": "^5.12",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.24",
"matomo/device-detector": "^6.4",
"spatie/laravel-permission": "^6.4",
"symfony/filesystem": "^7.2"
"spatie/laravel-permission": "^7.4",
"symfony/filesystem": "^8.0"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
"barryvdh/laravel-ide-helper": "^3.0",
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.0",
Expand All @@ -31,7 +30,8 @@
"laravel/sail": "^1.54",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.0.1"
"pestphp/pest": "^4.7",
"roave/security-advisories": "dev-latest"
},
"autoload": {
"psr-4": {
Expand Down Expand Up @@ -97,4 +97,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
}
}
146 changes: 61 additions & 85 deletions apps/api/tests/Feature/Auth/AuthenticationTest.php
Original file line number Diff line number Diff line change
@@ -1,106 +1,82 @@
<?php

namespace Tests\Feature\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;

class AuthenticationTest extends TestCase
{
use RefreshDatabase;

public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();

$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'password',
], [
'Origin' => isset(config('sanctum.stateful')[0]) ? config('sanctum.stateful')[0] : 'localhost',
]);

$response->assertStatus(200);

$response->assertJson(
fn(AssertableJson $json) => $json
->has('ok')
->where('ok', true)
->missing('token')
);
}

public function test_oauth_redirect_has_web_middleware_for_session_persistence(): void
{
// Issue #31: OAuth redirect should have 'web' middleware to maintain session
// between redirect to provider and callback from provider
uses(RefreshDatabase::class);

// Test that the redirect route has web middleware for session persistence
$routes = app('router')->getRoutes();
$redirectRoute = null;
test('users can authenticate using the login screen', function () {
$user = User::factory()->create();

foreach ($routes as $route) {
if ($route->getName() === 'login.provider.redirect') {
$redirectRoute = $route;
break;
}
}
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'password',
], [
'Origin' => isset(config('sanctum.stateful')[0]) ? config('sanctum.stateful')[0] : 'localhost',
]);

$response->assertStatus(200);

$response->assertJson(
fn (AssertableJson $json) => $json
->has('ok')
->where('ok', true)
->missing('token')
);
});

test('oauth redirect has web middleware for session persistence', function () {
$routes = app('router')->getRoutes();
$redirectRoute = null;

$this->assertNotNull($redirectRoute, 'OAuth redirect route should exist');
$this->assertContains('web', $redirectRoute->middleware(), 'OAuth redirect should have web middleware for session persistence');
foreach ($routes as $route) {
if ($route->getName() === 'login.provider.redirect') {
$redirectRoute = $route;
break;
}
}

public function test_session_based_logout_clears_server_session(): void
{
// Issue #31: SSR logout should clear session on server side
// Currently, logout only clears client-side cookies but server-side
// session remains, causing SSR to think user is still authenticated
expect($redirectRoute)->not->toBeNull();
expect($redirectRoute->middleware())->toContain('web');
});

$user = User::factory()->create();
test('session based logout clears server session', function () {
$user = User::factory()->create();

// Simulate session-based login
$this->actingAs($user, 'web');
$this->actingAs($user, 'web');
$this->assertAuthenticated('web');

// Verify user is authenticated
$this->assertAuthenticated('web');
$response = $this->postJson('/api/v1/logout');

// Perform logout
$response = $this->postJson('/api/v1/logout');
$response->assertStatus(200)
->assertJson(['ok' => true]);

$response->assertStatus(200)
->assertJson(['ok' => true]);
$this->assertGuest('web');
});

// After logout, session should be cleared
$this->assertGuest('web');
}
test('users can not authenticate with invalid password', function () {
$user = User::factory()->create();

public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();

$response = $this->post('/api/v1/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);

$response->assertJson(
fn(AssertableJson $json) => $json
->hasAll(['ok', 'message', 'errors'])
->where('ok', false)
->missing('token')
);
}
$response = $this->post('/api/v1/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);

public function test_users_can_logout(): void
{
/** @var User $user */
$user = User::factory()->create();
$response->assertJson(
fn (AssertableJson $json) => $json
->hasAll(['ok', 'message', 'errors'])
->where('ok', false)
->missing('token')
);
});

$this->actingAs($user);
$response = $this->post('/api/v1/logout');
test('users can logout', function () {
/** @var User $user */
$user = User::factory()->create();

$response->assertJson(['ok' => true], true);
}
}
$this->actingAs($user);
$response = $this->post('/api/v1/logout');

$response->assertJson(['ok' => true], true);
});
78 changes: 33 additions & 45 deletions apps/api/tests/Feature/Auth/EmailVerificationTest.php
Original file line number Diff line number Diff line change
@@ -1,65 +1,53 @@
<?php

namespace Tests\Feature\Auth;

use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;

class EmailVerificationTest extends TestCase
{
use RefreshDatabase;

protected function setUp(): void
{
parent::setUp();
uses(RefreshDatabase::class);

// Configure view engine to prevent file operations during testing
config(['view.engine_resolver' => function () {
return function ($path, $data = []) {
return '';
};
}]);
}
beforeEach(function () {
config(['view.engine_resolver' => function () {
return function ($path, $data = []) {
return '';
};
}]);
});

public function test_email_can_be_verified(): void
{
$this->withoutMiddleware();
test('email can be verified', function () {
$this->withoutMiddleware();

$user = User::factory()->unverified()->create();
$user = User::factory()->unverified()->create();

Event::fake();
Event::fake();

$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['ulid' => $user->ulid, 'hash' => hash('sha256', $user->email)]
);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['ulid' => $user->ulid, 'hash' => hash('sha256', $user->email)]
);

$response = $this->get($verificationUrl);
$response = $this->get($verificationUrl);

Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertJson(['ok' => true], true);
}
Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertJson(['ok' => true], true);
});

public function test_email_is_not_verified_with_invalid_hash(): void
{
$this->withoutMiddleware();
test('email is not verified with invalid hash', function () {
$this->withoutMiddleware();

$user = User::factory()->unverified()->create();
$user = User::factory()->unverified()->create();

$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['ulid' => $user->ulid, 'hash' => hash('sha256', 'wrong-email')]
);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['ulid' => $user->ulid, 'hash' => hash('sha256', 'wrong-email')]
);

$this->get($verificationUrl);
$this->get($verificationUrl);

$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
Loading
Loading