16 Commits

Author SHA1 Message Date
1ac5075b37 fix: update ServerErrorResponseTest to use dynamic file paths for exceptions
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m32s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m47s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m43s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m49s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m57s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 1m50s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 16m55s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 1m45s
2025-12-01 15:48:52 -05:00
ba2beca107 feat: implement NotFoundResponse and ServerErrorResponse classes with corresponding tests
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 4m16s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 4m17s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 4m27s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m32s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 4m15s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 3m1s
2025-12-01 14:55:34 -05:00
b5779afde9 feat: add unit tests for OpenApiController to validate YAML and JSON responses
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m37s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m35s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m52s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m43s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m19s
2025-12-01 11:41:09 -05:00
c91f35c0b1 feat: add unit tests for OpenApiController to validate YAML and JSON responses
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m34s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m37s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m30s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m39s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m43s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m19s
2025-12-01 11:30:46 -05:00
88098837a3 feat/swagger (#24)
Some checks are pending
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Waiting to run
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m39s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m44s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m41s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m55s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m59s
Reviewed-on: #24
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-12-01 16:22:42 +00:00
cd49507140 feat: add unit tests for User model name and email formatting
All checks were successful
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m57s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m7s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m8s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m42s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m19s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m55s
2025-11-30 19:43:55 -05:00
7792cac8b8 feat: add unit tests for User model name and email formatting
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m59s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Failing after 3m51s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 4m4s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m25s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 4m10s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Has been cancelled
2025-11-30 19:39:49 -05:00
eaff49b6a4 feat: add event dispatcher destructor and implement subscriber interface with tests (#23)
Some checks failed
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Failing after 2m2s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Failing after 1m52s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Failing after 1m58s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m30s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m29s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m24s
Reviewed-on: #23
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-30 20:28:22 +00:00
721008bdfc feat: implement Guzzle facade and update JwtMiddleware to use it (#22)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m59s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m51s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m11s
Reviewed-on: #22
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-25 16:51:45 +00:00
a9a5cb6216 chore: update Dockerfile to use official PHP CLI image for version 8.5.0
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m4s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m32s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m32s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m45s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m28s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m10s
2025-11-22 10:43:34 -05:00
0504956d9a chore: update PHP version in composer.json and Dockerfile from 8.4 to 8.5
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m7s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m43s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 6m47s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 3m8s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m58s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m43s
2025-11-22 10:36:13 -05:00
e9d4cee336 chore: update Postgres version in test configuration from 17 to 18
All checks were successful
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 4m4s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 4m15s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 4m40s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m52s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 4m43s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m9s
🏗️✨ Build Workflow / 🖥️ 🔨 Build (push) Successful in 17m11s
🏗️✨ Build Workflow / 🖥️ 🔨 Build Migrations (push) Successful in 2m47s
2025-11-20 09:10:30 -05:00
7d9eb96bea fix: make Scope attribute repeatable and improve scope handling in middleware (#21)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m55s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m55s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m58s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m1s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m40s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m0s
Reviewed-on: #21
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-19 19:32:52 +00:00
9b736eb879 feat: add JWK support for JWT validation and update dependencies (#20)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m4s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m59s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m29s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 4m2s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m48s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m49s
Reviewed-on: #20
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-17 23:22:53 +00:00
7aa14c0db3 more tests (#19)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m32s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m48s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m33s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m44s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m53s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 3m5s
Reviewed-on: #19
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-16 16:40:09 +00:00
474134c654 chore: add deployment configurations and tests for logger and dispatcher (#18)
All checks were successful
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m54s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m48s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 3m9s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 3m9s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m4s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 2m44s
Reviewed-on: #18
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
2025-11-14 18:04:49 +00:00
59 changed files with 2994 additions and 328 deletions

View File

@@ -26,6 +26,12 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Write Version File
run: |
echo $GITEA_REF_NAME > VERSION
sed -i "s/dev-version/${GITEA_REF_NAME}/g" src/Helpers/Version.php
- name: 🏗️ 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View File

@@ -38,7 +38,7 @@ jobs:
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
-p 5432 \
-d postgres:17
-d postgres:18
echo "Waiting for Postgres to start"
sleep 10

View File

@@ -5,7 +5,7 @@
<option name="interpreterName" value="composer-runtime" />
</PhpTestInterpreterSettings>
</CommandLine>
<TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" scope="XML" use_alternative_configuration_file="true" />
<TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" coverage_engine="PCov" scope="XML" use_alternative_configuration_file="true" />
<method v="2" />
</configuration>
</component>

View File

@@ -12,7 +12,7 @@ RUN composer install --optimize-autoloader --ignore-platform-reqs --no-dev
# Use the official PHP CLI image with Alpine Linux for the second stage
FROM php:8.4.14-alpine AS php
FROM siteworxpro/php:8.5.0-cli-alpine AS php
ARG KAFKA_ENABLED=0

View File

@@ -4,9 +4,9 @@ echo "Installing xDebug"
apk add make gcc linux-headers autoconf alpine-sdk
curl -sL https://github.com/xdebug/xdebug/archive/3.4.0.tar.gz -o 3.4.0.tar.gz
tar -xvf 3.4.0.tar.gz
cd xdebug-3.4.0 || exit
curl -sL https://github.com/xdebug/xdebug/archive/3.5.0alpha3.tar.gz -o 3.5.0alpha3.tar.gz
tar -xvf 3.5.0alpha3.tar.gz
cd xdebug-3.5.0alpha3 || exit
phpize
./configure --enable-xdebug
make
@@ -20,5 +20,5 @@ xdebug.client_host = host.docker.internal
" > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
cd ..
rm -rf xdebug-3.4.0
rm -rf 3.4.0.tar.gz
rm -rf xdebug-3.5.0alpha3
rm -rf 3.5.0alpha3.tar.gz

View File

@@ -8,7 +8,7 @@
}
},
"require": {
"php": "^8.4",
"php": "^8.5",
"league/route": "^6.2.0",
"illuminate/database": "^v12.34.0",
"spiral/roadrunner-http": "^v3.6.0",
@@ -21,12 +21,16 @@
"lcobucci/jwt": "^5.6",
"adhocore/cli": "^1.9",
"robinvdvleuten/ulid": "^5.0",
"monolog/monolog": "^3.9"
"monolog/monolog": "^3.9",
"react/promise": "^3",
"react/async": "^4",
"guzzlehttp/guzzle": "^7.10",
"zircote/swagger-php": "^5.7"
},
"require-dev": {
"phpunit/phpunit": "^12.4",
"mockery/mockery": "^1.6",
"squizlabs/php_codesniffer": "^3.12",
"squizlabs/php_codesniffer": "^4.0",
"lendable/composer-license-checker": "^1.2",
"phpstan/phpstan": "^2.1.31",
"kwn/php-rdkafka-stubs": "^2.2"

1289
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,11 @@ use Siteworxpro\App\Helpers\Env;
return [
'app' => [
'log_level' => Env::get('LOG_LEVEL', 'debug'),
'dev_mode' => Env::get('DEV_MODE', false, 'bool'),
],
/**
* The server configuration.
*/
@@ -47,7 +52,7 @@ return [
'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'),
'audience' => Env::get('JWT_AUDIENCE', 'my_audience'),
'issuer' => Env::get('JWT_ISSUER', 'my_issuer'),
'strict_validation' => Env::get('JWT_STRICT_VALIDATION', true, 'bool'),
'strict_validation' => Env::get('JWT_STRICT_VALIDATION', false, 'bool'),
],
'queue' => [

View File

@@ -37,6 +37,20 @@ services:
environment:
PHP_IDE_CONFIG: serverName=localhost
swagger-ui:
labels:
- "traefik.enable=true"
- "traefik.http.routers.swagger-ui.entrypoints=web-secure"
- "traefik.http.routers.swagger-ui.rule=Host(`localhost`) && PathPrefix(`/docs`)"
- "traefik.http.routers.swagger-ui.tls=true"
- "traefik.http.routers.swagger-ui.service=swagger-ui"
- "traefik.http.services.swagger-ui.loadbalancer.server.port=8080"
image: swaggerapi/swagger-ui:latest
container_name: swagger-ui
environment:
BASE_URL: /docs
URL: /.well-known/swagger.yaml
migration-container:
volumes:
- ./db/migrations:/app/db/migrations
@@ -83,13 +97,16 @@ services:
postgres:
condition: service_healthy
environment:
JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/
JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9
JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration
QUEUE_BROKER: redis
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
JWT_SIGNING_KEY: a-string-secret-at-least-256-bits-long
DEV_MODE: 1
## Kafka and Zookeeper for local development
kafka-ui:

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class Scope
{
public function __construct(
private array $scopes = []
) {
}
public function getScopes(): array
{
return $this->scopes;
}
}

View File

@@ -6,17 +6,20 @@ namespace Siteworxpro\App;
use League\Route\Http\Exception\MethodNotAllowedException;
use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
use Siteworxpro\App\Http\Middleware\ScopeMiddleware;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\HttpStatus\CodesEnum;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
@@ -69,8 +72,14 @@ class Api
$this->router = new Router();
$this->router->get('/', IndexController::class . '::get');
$this->router->post('/', IndexController::class . '::post');
$this->router->get('/healthz', HealthcheckController::class . '::get');
$this->router->group('/.well-known', function (RouteGroup $router) {
$router->get('/swagger.yaml', OpenApiController::class . '::get');
$router->get('/swagger.json', OpenApiController::class . '::get');
});
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new JwtMiddleware());
$this->router->middleware(new ScopeMiddleware());
@@ -104,28 +113,20 @@ class Api
$response = $this->router->handle($request);
$this->worker->respond($response);
} catch (MethodNotAllowedException | NotFoundException) {
$uri = '';
if (isset($request)) {
$uri = $request->getUri()->getPath();
}
$this->worker->respond(
JsonResponseFactory::createJsonResponse(
['status_code' => 404, 'reason_phrase' => 'Not Found'],
CodesEnum::NOT_FOUND
)
JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri))
);
} catch (\Throwable $e) {
Logger::error($e->getMessage());
Logger::error($e->getTraceAsString());
$json = ['status_code' => 500, 'reason_phrase' => 'Server Error'];
if (Config::get("server.dev_mode")) {
$json = [
'status_code' => 500,
'reason_phrase' => 'Server Error',
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
];
}
$this->worker->respond(
JsonResponseFactory::createJsonResponse($json, CodesEnum::INTERNAL_SERVER_ERROR)
JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e))
);
}
}

View File

@@ -4,7 +4,7 @@ declare(ticks=1);
namespace Siteworxpro\App\Async;
use Siteworxpro\App\Annotations\Async\HandlesMessage;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facades\Broker;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Annotations\Async\HandlesMessage;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Services\Facades\Logger;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Async;
namespace Siteworxpro\App\Attributes\Async;
use Attribute;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Events;
namespace Siteworxpro\App\Attributes\Events;
use Attribute;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Siteworxpro\App\Annotations\Guards;
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
use Siteworxpro\App\Services\Facades\Config;
@@ -32,16 +32,6 @@ readonly class Jwt
) {
}
/**
* Get the required audience from configuration, ignoring any local override.
*
* @return string The globally configured audience or an empty string if not set.
*/
public function getRequiredAudience(): string
{
return Config::get('jwt.audience') ?? '';
}
/**
* Get the expected audience for validation.
*

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class RequireAllScopes
{
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class Scope
{
/**
* @param array<int, string> $scopes the required scopes
* @param string $claim the claim to check for scopes
* @param string $separator the separator used to split scopes in the claim
*/
public function __construct(
private array $scopes = [],
private string $claim = 'scope',
private string $separator = ' '
) {
}
public function getScopes(): array
{
return $this->scopes;
}
public function getClaim(): string
{
return $this->claim;
}
public function getSeparator(): string
{
return $this->separator;
}
}

View File

@@ -8,6 +8,7 @@ use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\DemoCommand;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\Queue\TestJob;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facades\Config;
@@ -21,7 +22,7 @@ class App
public function __construct()
{
Kernel::boot();
$this->app = new Application('Php-Template', Config::get('app.version') ?? 'dev-master');
$this->app = new Application('Php-Template', Version::VERSION);
$this->app->add(new DemoCommand());
$this->app->add(new Start());

View File

@@ -6,7 +6,9 @@ namespace Siteworxpro\App\Controllers;
use League\Route\Http\Exception\NotFoundException;
use Nyholm\Psr7\ServerRequest;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Helpers\Version;
/**
* Class Controller
@@ -15,6 +17,18 @@ use Psr\Http\Message\ResponseInterface;
*
* @package Siteworxpro\App\Controllers
*/
#[OA\Info(
version: Version::VERSION,
description: "This is a template API built using Siteworxpro framework.",
title: "Siteworxpro Template API",
contact: new OA\Contact(
name: "Siteworxpro",
url: "https://www.siteworxpro.com",
email: "support@siteworxpro.com"
),
license: new OA\License('MIT', 'https://opensource.org/licenses/MIT')
)]
#[OA\Server(url: "https://localhost", description: "Local Server")]
abstract class Controller implements ControllerInterface
{
/**

View File

@@ -8,9 +8,11 @@ use Illuminate\Database\PostgresConnection;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\Models\Model;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
/**
* Class HealthcheckController
@@ -22,8 +24,13 @@ use Siteworxpro\HttpStatus\CodesEnum;
class HealthcheckController extends Controller
{
/**
* Handles the GET request for health check.
*
* @throws \JsonException
*/
#[OA\Get(path: '/healthz', tags: ['Healthcheck'])]
#[OA\Response(response: '200', description: 'Healthcheck OK')]
#[OA\Response(response: '503', description: 'Healthcheck Failed')]
public function get(ServerRequest $request): ResponseInterface
{
try {
@@ -47,7 +54,7 @@ class HealthcheckController extends Controller
}
return JsonResponseFactory::createJsonResponse(
['status_code' => 200, 'message' => 'Healthcheck OK']
new GenericResponse('Healthcheck OK', CodesEnum::OK->value)
);
}
}

View File

@@ -6,8 +6,12 @@ namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Annotations\Guards;
use Siteworxpro\App\Attributes\Guards;
use Siteworxpro\App\Docs\TokenSecurity;
use Siteworxpro\App\Docs\UnauthorizedResponse;
use Siteworxpro\App\Http\JsonResponseFactory;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Http\Responses\GenericResponse;
/**
* Class IndexController
@@ -22,19 +26,36 @@ class IndexController extends Controller
* @throws \JsonException
*/
#[Guards\Jwt]
#[Guards\Scope(['get.index'])]
#[Guards\Scope(['get.index', 'status.check'])]
#[Guards\RequireAllScopes]
#[OA\Get(path: '/', security: [new TokenSecurity()], tags: ['Examples'])]
#[OA\Response(
response: '200',
description: 'An Example Response',
content: new OA\JsonContent(ref: '#/components/schemas/GenericResponse')
)]
#[UnauthorizedResponse]
public function get(ServerRequest $request): ResponseInterface
{
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);
return JsonResponseFactory::createJsonResponse(new GenericResponse('Server is running'));
}
/**
* Handles the POST request for the index route.
*
* @throws \JsonException
*/
#[Guards\Jwt]
#[Guards\Scope(['post.index'])]
#[OA\Post(path: '/', security: [new TokenSecurity()], tags: ['Examples'])]
#[OA\Response(
response: '200',
description: 'An Example Response',
content: new OA\JsonContent(ref: '#/components/schemas/GenericResponse')
)]
#[UnauthorizedResponse]
public function post(ServerRequest $request): ResponseInterface
{
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);
return JsonResponseFactory::createJsonResponse(new GenericResponse('POST request received'));
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use OpenApi\Generator;
use Psr\Http\Message\ResponseInterface;
class OpenApiController extends Controller
{
/**
* Handles the GET request to generate and return the OpenAPI specification.
*
* @param ServerRequest $request
* @return ResponseInterface
*/
public function get(ServerRequest $request): ResponseInterface
{
$openapi = new Generator()->generate([
__DIR__ . '/../Controllers',
__DIR__ . '/../Models',
__DIR__ . '/../Http/Responses',
]);
$response = new Response();
if (
$request->getHeaderLine('Accept') === 'application/json' ||
str_contains($request->getUri()->getPath(), '.json')
) {
$response->getBody()->write($openapi->toJson());
return $response->withHeader('Content-Type', 'application/json');
}
$response->getBody()->write($openapi->toYaml());
return $response->withHeader('Content-Type', 'application/x-yaml');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Siteworxpro\App\Docs;
use OpenApi\Attributes as OA;
class TokenSecurity extends OA\SecurityScheme
{
public function __construct()
{
parent::__construct(
securityScheme: 'bearerAuth',
type: 'http',
description: 'JWT based authentication using Bearer tokens.',
bearerFormat: 'JWT',
scheme: 'bearer'
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Siteworxpro\App\Docs;
use OpenApi\Attributes as OA;
#[\Attribute]
class UnauthorizedResponse extends OA\Response
{
public function __construct()
{
parent::__construct(
response: '401',
description: 'Unauthorized - Authentication is required and has failed or has not yet been provided.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
properties: [
new OA\Property(property: 'status_code', type: 'integer', example: 401),
new OA\Property(property: 'message', type: 'string', example: 'Unauthorized'),
]
)
)
);
}
}

View File

@@ -7,7 +7,10 @@ namespace Siteworxpro\App\Events;
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use Siteworxpro\App\Annotations\Events\ListensFor;
use Siteworxpro\App\Attributes\Events\ListensFor;
use function React\Async\await;
use function React\Async\coroutine;
/**
* Class Dispatcher
@@ -29,6 +32,8 @@ class Dispatcher implements DispatcherContract, Arrayable
*/
private Collection $pushed;
private array $subscribers = [];
/**
* @var string LISTENERS_NAMESPACE The namespace where listeners are located
*/
@@ -40,6 +45,16 @@ class Dispatcher implements DispatcherContract, Arrayable
$this->registerListeners();
}
/**
* @throws \Throwable
*/
public function __destruct()
{
foreach ($this->pushed as $event => $payload) {
$this->dispatch($event, $payload);
}
}
/**
* Register event listeners based on the ListensFor attribute.
*
@@ -99,7 +114,7 @@ class Dispatcher implements DispatcherContract, Arrayable
*/
public function subscribe($subscriber): void
{
$this->listeners = array_merge($this->listeners, (array) $subscriber);
$this->subscribers[] = $subscriber;
}
/**
@@ -108,6 +123,7 @@ class Dispatcher implements DispatcherContract, Arrayable
* @param $event
* @param array $payload
* @return array|null
* @throws \Throwable
*/
public function until($event, $payload = []): array|null
{
@@ -121,6 +137,7 @@ class Dispatcher implements DispatcherContract, Arrayable
* @param array $payload
* @param bool $halt
* @return array|null
* @throws \Throwable
*/
public function dispatch($event, $payload = [], $halt = false): array|null
{
@@ -130,23 +147,46 @@ class Dispatcher implements DispatcherContract, Arrayable
$eventClass = $event;
}
// Handle subscribers as a coroutine
$promise = coroutine(function () use ($event, $payload, $halt, $eventClass, &$responses) {
foreach ($this->subscribers as $subscriber) {
if (method_exists($subscriber, 'handle')) {
$response = $subscriber->handle($event, $payload);
$responses[$eventClass] = $response;
if ($halt && $response !== null) {
return $responses;
}
}
}
return null;
});
$listeners = $this->listeners[$eventClass] ?? null;
// If no listeners, just await the subscriber promise
if ($listeners === null) {
return null;
return await($promise);
}
$responses = [];
foreach ($listeners as $listener) {
$response = $listener($event, $payload);
$responses[] = $response;
$responses[$eventClass] = $response;
if ($halt && $response !== null) {
return $response;
}
}
// Await the subscriber promise and merge responses
$promiseResponses = await($promise);
if (is_array($promiseResponses)) {
$responses = array_merge($responses, $promiseResponses);
}
return $responses;
}
@@ -167,6 +207,7 @@ class Dispatcher implements DispatcherContract, Arrayable
*
* @param $event
* @return void
* @throws \Throwable
*/
public function flush($event): void
{

View File

@@ -6,7 +6,7 @@ namespace Siteworxpro\App\Events\Listeners\Database;
use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Database\Events\ConnectionEvent;
use Siteworxpro\App\Annotations\Events\ListensFor;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger;
@@ -18,12 +18,15 @@ use Siteworxpro\App\Services\Facades\Logger;
class Connected extends Listener
{
/**
* @param ConnectionEvent $event
* @param mixed $event
* @param array $payload
* @return null
*/
public function __invoke($event, array $payload = []): null
public function __invoke(mixed $event, array $payload = []): null
{
if (!($event instanceof ConnectionEvent)) {
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
Logger::info("Database connection event", [get_class($event), $event->connectionName]);

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Subscribers;
use Illuminate\Contracts\Support\Arrayable;
abstract class Subscriber implements SubscriberInterface, Arrayable
{
public function toArray(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Subscribers;
interface SubscriberInterface
{
public function handle(string $eventName, mixed $payload): mixed;
}

10
src/Helpers/Version.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
class Version
{
public const string VERSION = 'dev-master';
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Siteworxpro\App\Http;
use Illuminate\Contracts\Support\Arrayable;
use Nyholm\Psr7\Response;
use Siteworxpro\HttpStatus\CodesEnum;
@@ -17,13 +18,19 @@ class JsonResponseFactory
/**
* Create a JSON response with the given data and status code.
*
* @param array $data The data to include in the response.
* @param array|Arrayable $data The data to include in the response.
* @param CodesEnum $statusCode The HTTP status code for the response.
* @return Response The JSON response.
* @throws \JsonException
*/
public static function createJsonResponse(array $data, CodesEnum $statusCode = CodesEnum::OK): Response
{
public static function createJsonResponse(
array|Arrayable $data,
CodesEnum $statusCode = CodesEnum::OK
): Response {
if ($data instanceof Arrayable) {
$data = $data->toArray();
}
return new Response(
status: $statusCode->value,
headers: [

View File

@@ -6,8 +6,10 @@ namespace Siteworxpro\App\Http\Middleware;
use Carbon\Carbon;
use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure;
@@ -21,10 +23,12 @@ use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Annotations\Guards\Jwt;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
/**
@@ -103,7 +107,7 @@ class JwtMiddleware extends Middleware
/** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getRequiredAudience() !== '') {
if ($jwtInstance->getAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience();
}
@@ -114,7 +118,7 @@ class JwtMiddleware extends Middleware
// Parse and validate the token with signature, time, issuer and audience constraints.
$jwt = new JwtFacade()->parse(
$token,
$this->getSignedWith(),
$this->getSignedWith($token),
Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())),
@@ -129,16 +133,21 @@ class JwtMiddleware extends Middleware
}
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'status_code' => CodesEnum::UNAUTHORIZED->value,
'message' => 'Unauthorized: Invalid token',
'errors' => $violations
], CodesEnum::UNAUTHORIZED);
} catch (InvalidTokenStructure) {
// Token could not be parsed due to malformed structure.
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'status_code' => CodesEnum::UNAUTHORIZED->value,
'message' => 'Unauthorized: Invalid token',
], CodesEnum::UNAUTHORIZED);
} catch (GuzzleException | \RuntimeException) {
return JsonResponseFactory::createJsonResponse([
'status_code' => CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => 'Token validation service unavailable or unknown error',
], CodesEnum::INTERNAL_SERVER_ERROR);
}
// Expose all token claims as request attributes for downstream consumers.
@@ -161,20 +170,30 @@ class JwtMiddleware extends Middleware
* @return SignedWith Signature constraint used during JWT parsing.
*
* @throws \RuntimeException When no signing key is configured.
* @throws \JsonException
*/
private function getSignedWith(): SignedWith
private function getSignedWith(string $token): SignedWith
{
$key = Config::get('jwt.signing_key');
$keyConfig = Config::get('jwt.signing_key');
if ($key === null) {
if ($keyConfig === null) {
throw new \RuntimeException('JWT signing key is not configured.');
}
// Load key either from file or raw text.
if (str_starts_with($key, 'file://')) {
$key = InMemory::file(substr($key, 7));
// file:// path to key
if (str_starts_with($keyConfig, 'file://')) {
$key = InMemory::file(substr($keyConfig, 7));
// openid jwks url
} elseif (str_contains($keyConfig, '.well-known/')) {
$jwt = explode('.', $token);
if (count($jwt) !== 3) {
throw new InvalidTokenStructure('Invalid JWT structure for JWKS key retrieval.');
}
$header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR);
$keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present
$key = $this->getJwksKey($keyConfig, $keyId);
} else {
$key = InMemory::plainText($key);
$key = InMemory::plainText($keyConfig);
}
// Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC.
@@ -184,4 +203,120 @@ class JwtMiddleware extends Middleware
return new SignedWith(new Hmac256(), $key);
}
private function getJwksKey(string $url, string $keyId): Key
{
$cached = Redis::get('jwks_key_' . $keyId);
if ($cached !== null) {
return InMemory::plainText($cached);
}
$openIdConfig = Guzzle::get($url);
$body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR);
$jwksUri = $body['jwks_uri'] ?? '';
if (empty($jwksUri)) {
throw new \RuntimeException('JWKS URI not found in OpenID configuration.');
}
$jwksResponse = Guzzle::get($jwksUri);
$jwksBody = json_decode(
$jwksResponse->getBody()->getContents(),
true,
JSON_THROW_ON_ERROR
);
// For simplicity, we take the first key in the JWKS.
$firstKey = array_filter(
$jwksBody['keys'],
fn($key) => $key['kid'] === $keyId
)[0] ?? $jwksBody['keys'][0] ?? null;
if (empty($firstKey)) {
throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId);
}
$n = $firstKey['n'];
$e = $firstKey['e'];
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) .
"-----END PUBLIC KEY-----\n";
Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600);
return InMemory::plainText($publicKeyPem);
}
/**
* Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'.
* Returns raw DER bytes; caller base64-encodes and wraps with PEM headers.
*/
private function convertJwkToPem(string $n, string $e): string
{
$modulus = $this->base64UrlDecode($n);
$exponent = $this->base64UrlDecode($e);
$derN = $this->derEncodeInteger($modulus);
$derE = $this->derEncodeInteger($exponent);
// RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER }
$rsaPublicKey = $this->derEncodeSequence($derN . $derE);
// AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL
$algId = hex2bin('300d06092a864886f70d0101010500');
// SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey
$subjectPublicKey = $this->derEncodeBitString($rsaPublicKey);
// SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
return $this->derEncodeSequence($algId . $subjectPublicKey);
}
private function base64UrlDecode(string $data): string
{
$data = strtr($data, '-_', '+/');
$pad = strlen($data) % 4;
if ($pad) {
$data .= str_repeat('=', 4 - $pad);
}
return base64_decode($data);
}
private function derEncodeLength(int $len): string
{
if ($len < 0x80) {
return chr($len);
}
$bytes = '';
while ($len > 0) {
$bytes = chr($len & 0xFF) . $bytes;
$len >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
private function derEncodeInteger(string $bytes): string
{
// Remove leading zeroes
$bytes = ltrim($bytes, "\x00");
if ($bytes === '') {
$bytes = "\x00";
}
// Ensure positive INTEGER (prepend 0x00 if MSB set)
if ((ord($bytes[0]) & 0x80) !== 0) {
$bytes = "\x00" . $bytes;
}
return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeSequence(string $bytes): string
{
return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes;
}
private function derEncodeBitString(string $bytes): string
{
// 0 unused bits + data
$payload = "\x00" . $bytes;
return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload;
}
}

View File

@@ -8,7 +8,8 @@ use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Annotations\Guards\Scope;
use Siteworxpro\App\Attributes\Guards\RequireAllScopes;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\HttpStatus\CodesEnum;
@@ -32,7 +33,7 @@ class ScopeMiddleware extends Middleware
* Expected user scopes are provided on the request under the attribute name \`scopes\`
* as an array of strings.
*
* @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute).
* @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute).
* @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher.
*
* @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response.
@@ -42,7 +43,7 @@ class ScopeMiddleware extends Middleware
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface | Dispatcher $handler
RequestHandlerInterface|Dispatcher $handler
): ResponseInterface {
// Attempt to resolve the route's callable [Controller instance, method name].
$callable = $this->extractRouteCallable($handler);
@@ -57,33 +58,55 @@ class ScopeMiddleware extends Middleware
// Ensure the controller exists and the method is defined before reflecting.
if (class_exists($class::class)) {
$reflectionClass = new \ReflectionClass($class);
if ($reflectionClass->hasMethod($method)) {
$reflectionMethod = $reflectionClass->getMethod($method);
// Fetch all Scope attributes declared on the method.
$attributes = $reflectionMethod->getAttributes(Scope::class);
$requireAllAttributes = $reflectionMethod->getAttributes(RequireAllScopes::class);
if (empty($attributes)) {
// No scope attributes; delegate to the next handler.
return $handler->handle($request);
}
$requiredScopes = [];
$userScopes = [];
$requireAll = false;
foreach ($attributes as $attribute) {
/** @var Scope $scopeInstance Concrete Scope attribute instance. */
$scopeInstance = $attribute->newInstance();
$requiredScopes = $scopeInstance->getScopes();
$requiredScopes = array_merge($requiredScopes, $scopeInstance->getScopes());
// Retrieve user scopes from the request (defaults to an empty array).
$userScopes = $request->getAttribute('scopes', []);
// If any attribute requires all scopes, set the flag.
$requireAll = $requireAll || !empty($requireAllAttributes);
// Deny if any required scope is missing from the user's scopes.
if (
array_any(
$requiredScopes,
fn($requiredScope) => !in_array($requiredScope, $userScopes, true)
)
) {
return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope',
'error_description' =>
'The request requires higher privileges than provided by the access token.'
], CodesEnum::FORBIDDEN);
$scopes = $request->getAttribute($scopeInstance->getClaim());
if (!is_array($scopes)) {
// If user scopes are not an array, treat as no scopes provided.
$scopes = explode($scopeInstance->getSeparator(), (string) $scopes);
}
$userScopes = array_merge(
$userScopes,
$scopes
);
}
$userScopes = array_unique($userScopes);
// Deny if any required scope is missing from the user's scopes.
if (
(!$requireAll && array_intersect($userScopes, $requiredScopes) === []) ||
($requireAll && array_diff($requiredScopes, $userScopes) !== [])
) {
return JsonResponseFactory::createJsonResponse([
'error' => 'insufficient_scope',
'error_description' =>
'The request requires higher privileges than provided by the access token.'
], CodesEnum::FORBIDDEN);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'GenericResponse',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Operation completed successfully.'),
new OA\Property(property: 'status_code', type: 'integer', example: 200),
]
)]
readonly class GenericResponse implements Arrayable
{
public function __construct(
private string $message = '',
private int $statusCode = 200
) {
}
public function toArray(): array
{
return [
'message' => $this->message,
'status_code' => $this->statusCode,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'NotFoundResponse',
properties: [
new OA\Property(
property: 'message',
type: 'string',
example: 'The requested resource /api/resource was not found.'
),
new OA\Property(property: 'status_code', type: 'integer', example: 404),
new OA\Property(
property: 'context',
description: 'Additional context about the not found error.',
type: 'object',
example: '{}'
),
]
)]
readonly class NotFoundResponse implements Arrayable
{
public function __construct(private string $uri, private array $context = [])
{
}
public function toArray(): array
{
return [
'status_code' => CodesEnum::NOT_FOUND->value,
'message' => 'The requested resource ' . $this->uri . ' was not found.',
'context' => $this->context,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'ServerErrorResponse',
properties: array(
new OA\Property(property: 'message', type: 'string', example: 'An internal server error occurred.'),
new OA\Property(property: 'status_code', type: 'integer', example: 500),
new OA\Property(
property: 'file',
type: 'string',
example: '/var/www/html/app/Http/Controllers/ExampleController.php'
),
new OA\Property(property: 'line', type: 'integer', example: 42),
new OA\Property(
property: 'trace',
type: 'array',
items: new OA\Items(type: 'string'),
)
)
)]
readonly class ServerErrorResponse implements Arrayable
{
public function __construct(private \Throwable $e, private array $context = [])
{
}
public function toArray(): array
{
if (Config::get('app.dev_mode')) {
return [
'status_code' => $this->e->getCode() != 0 ?
$this->e->getCode() :
CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => $this->e->getMessage(),
'file' => $this->e->getFile(),
'line' => $this->e->getLine(),
'trace' => $this->e->getTrace(),
'context' => $this->context,
];
}
return [
'status_code' => $this->e->getCode() != 0 ?
$this->e->getCode() :
CodesEnum::INTERNAL_SERVER_ERROR->value,
'message' => 'An internal server error occurred.',
];
}
}

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
/**
* Class User
*
* @property string $id
* @property-read string $id
* @property string $first_name
* @property string $last_name
* @property string $email
@@ -19,6 +21,23 @@ use Carbon\Carbon;
* @property-read string $full_name
* @property-read string $formatted_email
*/
#[OA\Schema(
schema: "User",
properties: [
new OA\Property(
property: "id",
description: "Unique identifier for the user",
type: "string",
format: "ulid",
readOnly: true,
example: '01KBD5WPZKYD77BYM2QD9NKG99'
),
new OA\Property(property: "first_name", type: "string"),
new OA\Property(property: "last_name", type: "string"),
new OA\Property(property: "email", type: "string", format: "email"),
new OA\Property(property: "created_at", type: "string", format: "date-time"),
]
)]
class User extends Model
{
protected $casts = [
@@ -36,6 +55,12 @@ class User extends Model
'password',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate();
}
public function getFullNameAttribute(): string
{
return "$this->first_name $this->last_name";

View File

@@ -14,6 +14,7 @@ use Siteworxpro\App\Services\Facade;
* It extends the Facade class from the Illuminate\Support\Facades namespace.
*
* @method static array | bool | string | int | null get(string $key) Retrieve the configuration value for the given key. // @codingStandardsIgnoreStart
* @method static void set(string $key, mixed $value) Set the configuration value for the given key. // @codingStandardsIgnoreEnd
*
* @package Siteworx\App\Facades
*/

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Response;
use Siteworxpro\App\Services\Facade;
/**
* @method static Response get(string $uri, array $options = [])
* @method static Response post(string $uri, array $options = [])
* @method static Response put(string $uri, array $options = [])
* @method static Response delete(string $uri, array $options = [])
* @method static Response patch(string $uri, array $options = [])
* @method static Response head(string $uri, array $options = [])
* @method static PromiseInterface sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = [])
* @method static PromiseInterface requestAsync(string $method, string $uri, array $options = [])
*/
class Guzzle extends Facade
{
protected static function getFacadeAccessor(): string
{
return Client::class;
}
}

View File

@@ -6,6 +6,7 @@ namespace Siteworxpro\App\Services\ServiceProviders;
use Illuminate\Support\ServiceProvider;
use Siteworxpro\App\Log\Logger;
use Siteworxpro\App\Services\Facades\Config;
/**
* Class LoggerServiceProvider
@@ -17,7 +18,7 @@ class LoggerServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->singleton(Logger::class, function () {
return new Logger();
return new Logger(Config::get('app.log_level'));
});
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Attributes\Guards;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit;
class JwtTest extends Unit
{
public function testGetsClassFromConfig(): void
{
Config::set('jwt.issuer', 'default-issuer');
Config::set('jwt.audience', 'default-audience');
$reflection = new \ReflectionClass(TestClass::class);
$attributes = $reflection->getAttributes(Jwt::class);
$this->assertCount(1, $attributes);
/** @var Jwt $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals('default-audience', $instance->getAudience());
$this->assertEquals('default-issuer', $instance->getIssuer());
}
public function testGetsClassFromCustom(): void
{
$reflection = new \ReflectionClass(TestClassSpecific::class);
$attributes = $reflection->getAttributes(Jwt::class);
$this->assertCount(1, $attributes);
/** @var Jwt $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals('custom-audience', $instance->getAudience());
$this->assertEquals('custom-issuer', $instance->getIssuer());
}
}
#[Jwt]
class TestClass // @codingStandardsIgnoreLine
{
}
#[Jwt('custom-issuer', 'custom-audience')]
class TestClassSpecific // @codingStandardsIgnoreLine
{
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Attributes\Guards;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\Tests\Unit;
class ScopeTest extends Unit
{
public function testGetsClassSingle(): void
{
$reflection = new \ReflectionClass(TestClassSingle::class);
$attributes = $reflection->getAttributes(Scope::class);
$this->assertCount(1, $attributes);
/** @var Scope $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals(['read:users'], $instance->getScopes());
}
public function testGetsClassFromCustom(): void
{
$reflection = new \ReflectionClass(TestClassMultiple::class);
$attributes = $reflection->getAttributes(Scope::class);
$this->assertCount(1, $attributes);
/** @var Scope $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals(['read:users', 'write:users'], $instance->getScopes());
}
}
#[Scope(['read:users', 'write:users'])]
class TestClassMultiple // @codingStandardsIgnoreLine
{
}
#[Scope(['read:users'])]
class TestClassSingle // @codingStandardsIgnoreLine
{
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Attributes;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\Tests\Unit;
class HandlesMessageTest extends Unit
{
public function testGetsClass(): void
{
$class = new #[HandlesMessage('Siteworxpro\Tests\Attributes\TestClass')] class {
};
$reflection = new \ReflectionClass($class);
$attributes = $reflection->getAttributes(HandlesMessage::class);
$this->assertCount(1, $attributes);
/** @var HandlesMessage $instance */
$instance = $attributes[0]->newInstance();
$this->assertEquals('Siteworxpro\Tests\Attributes\TestClass', $instance->getMessageClass());
}
}

View File

@@ -20,6 +20,21 @@ class IndexControllerTest extends AbstractController
$response = $controller->get($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"status_code":200,"message":"Server is running"}', (string)$response->getBody());
$this->assertEquals('{"message":"Server is running","status_code":200}', (string)$response->getBody());
}
/**
* @throws \JsonException
*/
public function testPost(): void
{
$this->assertTrue(true);
$controller = new IndexController();
$response = $controller->post($this->getMockRequest());
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('{"message":"POST request received","status_code":200}', (string)$response->getBody());
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Controllers;
use Siteworxpro\App\Controllers\OpenApiController;
class OpenApiControllerTest extends ControllerTest
{
public function testBuildsYaml(): void
{
$request = $this->getMockRequest('/.well-known/openapi.yaml');
$controller = new OpenApiController();
$response = $controller->get($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringContainsString('openapi: 3.0.0', (string)$response->getBody());
}
public function testBuildsJson(): void
{
$request = $this->getMockRequest(uri: '/.well-known/openapi.json');
$controller = new OpenApiController();
$response = $controller->get($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
$this->assertNotFalse(json_decode($response->getBody()->getContents()));
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Events;
use Illuminate\Contracts\Container\BindingResolutionException;
use Siteworxpro\Tests\Unit;
class DispatcherTest extends Unit
{
/**
* @throws \Throwable
* @throws BindingResolutionException
*/
public function testRegistersListeners(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventFired = false;
$dispatcher->listen('TestEvent', function ($event) use (&$eventFired) {
$this->assertEquals('TestEvent', $event);
$eventFired = true;
});
$dispatcher->dispatch('TestEvent');
$this->assertTrue($eventFired, 'The TestEvent listener was not fired.');
}
/**
* @throws BindingResolutionException
*/
public function testPushesEvents()
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventsFired = 0;
$dispatcher->listen('PushedEvent1', function ($event) use (&$eventsFired) {
$eventsFired++;
$this->assertEquals('PushedEvent1', $event);
});
$dispatcher->listen('PushedEvent2', function ($event) use (&$eventsFired) {
$eventsFired++;
$this->assertEquals('PushedEvent2', $event);
});
$dispatcher->push('PushedEvent1');
$dispatcher->push('PushedEvent2');
unset($dispatcher); // Trigger destructor
$this->assertEquals(2, $eventsFired);
}
/**
* @throws BindingResolutionException
* @throws \Throwable
*/
public function testFlushEvent(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventFired = false;
$dispatcher->listen('FlushEvent', function ($event) use (&$eventFired) {
$this->assertEquals('FlushEvent', $event);
$eventFired = true;
});
$dispatcher->push('FlushEvent');
$dispatcher->flush('FlushEvent');
$this->assertTrue($eventFired, 'The FlushEvent listener was not fired.');
}
/**
* @throws BindingResolutionException
*/
public function testHasListeners(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$this->assertFalse(
$dispatcher->hasListeners(
'NonExistentEvent'
),
'Expected no listeners for NonExistentEvent.'
);
$dispatcher->listen('ExistingEvent', function () {
// Listener logic
});
$this->assertTrue(
$dispatcher->hasListeners(
'ExistingEvent'
),
'Expected listeners for ExistingEvent.'
);
}
/**
* @throws BindingResolutionException
* @throws \Throwable
*/
public function testForgetEvent(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$eventFired = false;
$dispatcher->listen('ForgetEvent', function ($event) use (&$eventFired) {
$this->assertEquals('ForgetEvent', $event);
$eventFired = true;
});
$dispatcher->push('ForgetEvent');
$dispatcher->forget('ForgetEvent');
unset($dispatcher); // Trigger destructor
$this->assertFalse($eventFired, 'The ForgetEvent listener was fired but should have been forgotten.');
}
/**
* @throws BindingResolutionException
*/
public function testForgetPushed()
{
$this->expectNotToPerformAssertions();
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$dispatcher->listen('EventToForget', function () {
$this->fail('The EventToForget listener was fired but should have been forgotten.');
});
$dispatcher->push('EventToForget');
$dispatcher->forgetPushed();
unset($dispatcher); // Trigger destructor
}
/**
* @throws BindingResolutionException
*/
public function testToArray(): void
{
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$dispatcher->listen('ArrayEvent', function () {
// Listener logic
});
$arrayRepresentation = $dispatcher->toArray();
$this->assertArrayHasKey('ArrayEvent', $arrayRepresentation);
}
/**
* @throws BindingResolutionException
* @throws \Throwable
*/
public function testSubscriber()
{
$subscriber = $this->getMockBuilder('Siteworxpro\App\Events\Subscribers\Subscriber')
->onlyMethods(['handle'])
->getMock();
$subscriber->expects($this->once())
->method('handle')
->with('SubscribedEvent', [])
->willReturn(null);
$dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher');
$dispatcher->subscribe($subscriber);
$dispatcher->dispatch('SubscribedEvent');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Events\Listeners;
use Illuminate\Database\Events\ConnectionEstablished;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LogLevel;
use Siteworxpro\App\Events\Listeners\Database\Connected;
use Siteworxpro\App\Log\Logger;
use Siteworxpro\Tests\Unit;
class ConnectedTest extends Unit
{
/**
* @throws ContainerExceptionInterface
* @throws \ReflectionException
* @throws NotFoundExceptionInterface
*/
protected function setUp(): void
{
parent::setUp();
$inputBuffer = fopen('php://memory', 'r+');
$logger = new Logger(LogLevel::DEBUG, $inputBuffer);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()->bind(Logger::class, fn() => $logger);
}
public function testHandlesEvent()
{
$this->expectNotToPerformAssertions();
$connectedEvent = $this->createMock(ConnectionEstablished::class);
$listener = new Connected();
$listener->__invoke($connectedEvent);
}
public function testThrowsException()
{
$this->expectException(\TypeError::class);
$listener = new Connected();
$listener->__invoke(new \stdClass());
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Facades;
use GuzzleHttp\Client;
use Siteworxpro\App\Services\Facades\Guzzle;
class GuzzleTest extends AbstractFacade
{
protected function getFacadeClass(): string
{
return Guzzle::class;
}
protected function getConcrete(): string
{
return Client::class;
}
}

View File

@@ -11,7 +11,7 @@ use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit;
class CorsMiddlewareTest extends Unit
class CorsMiddlewareTest extends Middleware
{
public function testAllowsConfiguredOrigin(): void
{
@@ -80,22 +80,4 @@ class CorsMiddlewareTest extends Unit
$this->assertEquals('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
}
private function mockHandler(Response $response): RequestHandlerInterface
{
return new class ($response) implements RequestHandlerInterface {
private Response $response;
public function __construct(Response $response)
{
$this->response = $response;
}
public function handle(
\Psr\Http\Message\ServerRequestInterface $request
): \Psr\Http\Message\ResponseInterface {
return $this->response;
}
};
}
}

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Http\Middleware;
use DateTimeImmutable;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Token\Builder;
use League\Route\Dispatcher;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
class JwtMiddlewareTest extends Middleware
{
private const string TEST_SIGNING_KEY = 'test_signing_key_123456444478901234';
private const string TEST_RSA_PRIVATE_KEY = <<<EOD
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAqTheAdlelxJL0K15BqUEo0lBzY06P7J0PhMfPlg2fgIJH+ng
ZmrpYFhBkj2L5Fnvxz0y58eu9WhhokwpS0GzgFIw+KfLV/WLX4PgionsQshrt0Pi
XvthaSH1xuYtg2N13dVVTv3Au0BBFLUHMrQ+bO5hgvowHBNfFf0GaHLW2m0eZ2Um
hWbtdv4HxrXBO5gI2N4UevyQ+inczN7RBZR6ZzyNoDO6Up6kS23/58zOruO+PGi7
q9eb7hU+getpVgA29wEWMgT+N6c5n5AcENgM1sHxZK43GR5vhMGbVJqnrUsMGof7
rT9Lxey3gjPS2r5nz2PNFcQ1i07QKDzvQHp2wwIDAQABAoIBAFMAC9QaWzP8TGWJ
gNBKhnDU0MrSl5yAmlWMKYn52JiLxQ/7Ng7mJ5wTDe5986zIlDyEfwCCyAUk8qaZ
drOsATBSoCSGoM1+6aKq26r4JYNILNVSHal64XegqZ2qbu6ADWMGbXZ2Ll9qD8Hp
XSN4lxn0/q0wrAJJWh094zO+CDZP+zBbX9oHxb5JAVxjCaNW84sI6/6agXM5zzgK
wcBt5Y0i8V8f7n9kg+CPNqY6BKg7o2ONFYTEVKuuEnVS/eupHQwBWExPCdxc85Tb
YqFL0dmgehE0OTQ6FrEN7Xh6jE4GMJtWmTvBNpqhsMZ0i08tAZSPs+Us9rnppKkK
T1SC2xECgYEA7yOv4C7dtHmFbn0YfnbBEfgvGAubv5jPDtZ5u6tUEhhU3rOcWexM
Xhj7OFV4I8lbu2t7GY+2BR7Y2ikOLW9MrOGo6qWhsjTQuZs6QaRKObcPvl2s0LYY
GxD1u84VjHPzID2pKVPqxaQ7KdcIaujAedWwAf4PV/uK2prKdGvzIksCgYEAtSau
4Ml1UpXvKxiBcVKsHIoEO0g3NL1+wAbdStg8TFi+leCMJoPwZ01t64BTtHF+pgDP
vn6VEgDSP3J4+W3dVhoajQeKBioT3MpDRP/qKDsImi2zJrg+hh9DMTlZd0Ab3EXv
ycjw3FWRcpcU/1l261fA/m3QPwZikF2VlO/0cmkCgYEAvtefCuy718RHHObOPlZt
O/bxNmJFOEEttOyql39iB1LNoDB8bTLruwh6q/lheEXAZDChO8P5gdqdOnUbMF0r
Nqib0i6+fOYzUHw1oJ8I8UhLUyOUv7ciQ69kPC15+u2psCglMKscp/+pi3lk6VS4
DkLfRKfI/PDsXgq72O8xSEMCgYEApukSnvngyQxvR1UYB7N19AHTLlA21bh4LjTk
905QGMR4Lp6sY9yTyIsWabRe69bbK9d5kvsNHX52OpGeF6z8EJaSujklGtLwZDJV
UyE9vn3OSkkrVdTTfz8U6Sj/XxpJ0Wb7LwCftVR+ZIgCh9kF8ohzwbqq8zdN39jq
t0V1BWkCgYEA2Mk2gOdYAN8aZgydFYKhogY5UNK/CFpq7hhekEyt73uxzxguVpZn
AJ9mq2L1CVJ5WqAUk2IzioeR7XAndntesbOafDuR4mhCUJhX+m/YQlKbTrs2dScR
S88z05AnmQmr5eCbQmVULZGo9xeLDB+GDWvvjpQ+NWcha2uO0O0RTQY=
-----END RSA PRIVATE KEY-----
EOD;
private const string TEST_JWKS_JSON = <<<EOD
{
"keys": [
{
"alg": "RS256",
"e": "AQAB",
"ext": true,
"key_ops": [
"verify"
],
"kty": "RSA",
"n": "qTheAdlelxJL0K15BqUEo0lBzY06P7J0PhMfPlg2fgIJH-ngZmrpYFhBkj2L5Fnvxz0y58eu9WhhokwpS0GzgFIw-KfLV_WLX4PgionsQshrt0PiXvthaSH1xuYtg2N13dVVTv3Au0BBFLUHMrQ-bO5hgvowHBNfFf0GaHLW2m0eZ2UmhWbtdv4HxrXBO5gI2N4UevyQ-inczN7RBZR6ZzyNoDO6Up6kS23_58zOruO-PGi7q9eb7hU-getpVgA29wEWMgT-N6c5n5AcENgM1sHxZK43GR5vhMGbVJqnrUsMGof7rT9Lxey3gjPS2r5nz2PNFcQ1i07QKDzvQHp2ww",
"kid": "2o5IaHnjxYtkpNWEcdPlwnaRJnaCJ2k2LY2nR4z6cN4=",
"use": "sig"
}
]
}
EOD;
public function getClass(): object
{
return new class {
public function getCallable(): array
{
return [$this, 'index'];
}
#[Jwt]
public function index()
{
// Dummy method for testing
}
};
}
protected function setUp(): void
{
parent::setUp();
Config::set('jwt.signing_key', self::TEST_SIGNING_KEY);
}
/**
* @throws \JsonException
*/
public function testIgnoresNoJwtAttribute()
{
$class = new class {
public function getCallable(): array
{
return [$this, 'index'];
}
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testIgnoresJwtAttributeButNoToken()
{
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testInvalidToken()
{
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . 'invalid_token_string');
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'Unauthorized: Invalid token',
$response->getBody()->getContents()
);
}
/**
* @throws \JsonException
*/
public function testJwtAttributeWithTokenButWrongAud()
{
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'The token is not allowed to be used by this audience',
$response->getBody()->getContents()
);
}
/**
* @throws \JsonException
*/
public function testJwtAttributeWithTokenButWrongIss()
{
Config::set('jwt.audience', 'https://client-app.io');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'The token was not issued by the given issuers',
$response->getBody()->getContents()
);
}
/**
* @throws \JsonException
*/
public function testJwtAttributeWithTokenWithDiffIssuer()
{
Config::set('jwt.audience', 'https://client-app.io');
Config::set('jwt.issuer', 'https://different-issuer.io');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode());
$this->assertStringContainsString(
'The token was not issued by the given issuers',
$response->getBody()->getContents()
);
}
public function testJwtAttributeWithToken()
{
Config::set('jwt.audience', 'https://client-app.io');
Config::set('jwt.issuer', 'https://api.my-awesome-app.io');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testJwtFromJwkEndpoint()
{
Config::set('jwt.audience', 'https://client-app.io');
Config::set('jwt.issuer', 'https://api.my-awesome-app.io');
Redis::partialMock()->shouldReceive('get')->andReturn(null);
Redis::shouldReceive('set')->andReturn('OK');
Guzzle::partialMock()->shouldReceive('get')
->with('https://test.com/.well-known/openid-configuration')
->andReturn(new Response(200, [], json_encode([
'jwks_uri' => 'https://test.com/keys'
], JSON_THROW_ON_ERROR)));
Guzzle::shouldReceive('get')
->with('https://test.com/keys')
->andReturn(new Response(200, [], self::TEST_JWKS_JSON));
Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration');
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \JsonException
*/
public function testCatchesInvalidJwksUrl()
{
Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration');
Redis::partialMock()->shouldReceive('get')->andReturn(null);
Redis::shouldReceive('set')->andReturn('OK');
Guzzle::partialMock()->shouldReceive('get')
->with('https://test.com/.well-known/openid-configuration')
->andReturn(new Response(200, [], json_encode([], JSON_THROW_ON_ERROR)));
$class = $this->getClass();
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa());
$middleware = new JwtMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::INTERNAL_SERVER_ERROR->value, $response->getStatusCode());
}
private function getJwtRsa(): string
{
$key = InMemory::plainText(self::TEST_RSA_PRIVATE_KEY);
$signer = new \Lcobucci\JWT\Signer\Rsa\Sha256();
$token = new JwtFacade()->issue(
$signer,
$key,
static fn(
Builder $builder,
DateTimeImmutable $issuedAt
): Builder => $builder
->issuedBy('https://api.my-awesome-app.io')
->permittedFor('https://client-app.io')
->expiresAt($issuedAt->modify('+10 minutes'))
);
return $token->toString();
}
private function getJwt(): string
{
$key = InMemory::plainText(self::TEST_SIGNING_KEY);
$signer = new Sha256();
$token = new JwtFacade()->issue(
$signer,
$key,
static fn(
Builder $builder,
DateTimeImmutable $issuedAt
): Builder => $builder
->issuedBy('https://api.my-awesome-app.io')
->permittedFor('https://client-app.io')
->expiresAt($issuedAt->modify('+10 minutes'))
);
return $token->toString();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Http\Middleware;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\Tests\Unit;
abstract class Middleware extends Unit
{
protected function mockHandler(Response $response): RequestHandlerInterface
{
return new class ($response) implements RequestHandlerInterface {
private Response $response;
public function __construct(Response $response)
{
$this->response = $response;
}
public function handle(
ServerRequestInterface $request
): ResponseInterface {
return $this->response;
}
};
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Http\Middleware;
use League\Route\Dispatcher;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Http\Middleware\ScopeMiddleware;
use Siteworxpro\HttpStatus\CodesEnum;
class ScopeMiddlewareTest extends Middleware
{
/**
* @throws \ReflectionException
* @throws \JsonException
*/
public function testHandlesNoScopes()
{
$class = new class {
public function getCallable(): array
{
return [ $this, 'index' ];
}
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/');
$middleware = new ScopeMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(200, $response->getStatusCode());
}
/**
* @throws \ReflectionException
* @throws \JsonException
*/
public function testAllowsWithScope()
{
$class = new class {
public function getCallable(): array
{
return [ $this, 'index' ];
}
#[Scope(['admin'])]
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$handler
->shouldReceive('handle')
->once()
->andReturn(new Response(200));
$request = new ServerRequest('GET', '/')->withAttribute('scope', ['admin', 'user']);
$middleware = new ScopeMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::OK->value, $response->getStatusCode());
}
/**
* @throws \ReflectionException
* @throws \JsonException
*/
public function testDisallowsWithScope()
{
$class = new class {
public function getCallable(): array
{
return [ $this, 'index' ];
}
#[Scope(['admin'])]
public function index()
{
// Dummy method for testing
}
};
$handler = \Mockery::mock(Dispatcher::class);
$handler->shouldReceive('getMiddlewareStack')
->andReturn([$class]);
$request = new ServerRequest('GET', '/');
$middleware = new ScopeMiddleware();
$response = $middleware->process($request, $handler);
$this->assertEquals(CodesEnum::FORBIDDEN->value, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Siteworxpro\Tests\Http\Responses;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\Tests\Unit;
class NotFoundResponseTest extends Unit
{
public function testToArray(): void
{
$response = new NotFoundResponse('/api/resource', ['key' => 'value']);
$expected = [
'status_code' => 404,
'message' => 'The requested resource /api/resource was not found.',
'context' => ['key' => 'value'],
];
$this->assertEquals($expected, $response->toArray());
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Siteworxpro\Tests\Http\Responses;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\Tests\Unit;
class ServerErrorResponseTest extends Unit
{
public function testToArrayInDevMode(): void
{
Config::set('app.dev_mode', true);
try {
// Simulate an exception to generate a server error response
throw new \Exception('A Test Error occurred.');
} catch (\Exception $e) {
$response = new ServerErrorResponse($e, ['operation' => 'data_processing']);
$expected = [
'status_code' => 500,
'message' => 'A Test Error occurred.',
'context' => [
'operation' => 'data_processing'
],
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTrace(),
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayNotInDevMode(): void
{
try {
throw new \Exception('A Test Error occurred.');
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'status_code' => 500,
'message' => 'An internal server error occurred.',
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayIfCodeIsSet(): void
{
try {
throw new \Exception('A Test Error occurred.', 1234);
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'status_code' => 1234,
'message' => 'An internal server error occurred.',
];
$this->assertEquals($expected, $response->toArray());
}
}
public function testToArrayIfCodeIsSetDevMode(): void
{
Config::set('app.dev_mode', true);
try {
throw new \Exception('A Test Error occurred.', 1234);
} catch (\Exception $exception) {
$response = new ServerErrorResponse($exception);
$expected = [
'status_code' => 1234,
'message' => 'A Test Error occurred.',
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace(),
'context' => [],
];
$this->assertEquals($expected, $response->toArray());
}
}
}

View File

@@ -34,7 +34,7 @@ class LoggerRpcTest extends Unit
$mock = Mockery::mock(LoggerInterface::class);
$mock->expects('debug')
->with('message', ['key' => 'value'])
->once();
->times(1);
\Siteworxpro\App\Services\Facades\Logger::getFacadeContainer()
->bind(\RoadRunner\Logger\Logger::class, function () use ($mock) {
@@ -46,8 +46,6 @@ class LoggerRpcTest extends Unit
$logger->debug('message', ['key' => 'value']);
$mock->shouldHaveReceived('debug');
Mockery::close();
}
/**
@@ -76,7 +74,6 @@ class LoggerRpcTest extends Unit
$logger->notice('message', ['key' => 'value']);
$mock->shouldHaveReceived('info')->times(2);
Mockery::close();
}
/**
@@ -104,7 +101,6 @@ class LoggerRpcTest extends Unit
$logger->warning('message', ['key' => 'value']);
$mock->shouldHaveReceived('warning');
Mockery::close();
}
/**
@@ -135,7 +131,6 @@ class LoggerRpcTest extends Unit
$logger->emergency('message', ['key' => 'value']);
$mock->shouldHaveReceived('error')->times(4);
Mockery::close();
}
/**
@@ -162,6 +157,5 @@ class LoggerRpcTest extends Unit
$logger->log('notaloglevel', 'message', ['key' => 'value']);
$mock->shouldHaveReceived('log')->times(1);
Mockery::close();
}
}

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
namespace Siteworxpro\Tests\Log;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LogLevel;
use Siteworxpro\App\Log\Logger;
use Siteworxpro\Tests\Unit;
class LoggerTest extends Unit
{
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function getLoggerWithBuffer(string $logLevel): array
{
$inputBuffer = fopen('php://memory', 'r+');
@@ -21,6 +27,10 @@ class LoggerTest extends Unit
return stream_get_contents($inputBuffer, -1, 0);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function testLogLevel(string $level): void
{
[$logger, $inputBuffer] = $this->getLoggerWithBuffer($level);
@@ -33,6 +43,10 @@ class LoggerTest extends Unit
$this->assertEquals('value', $decoded['context']['key']);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function testLogLevelEmpty(string $configLevel, string $logLevel): void
{
[$logger, $inputBuffer] = $this->getLoggerWithBuffer($configLevel);
@@ -42,57 +56,101 @@ class LoggerTest extends Unit
$this->assertEmpty($output);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsDebugMessageWhenLevelIsDebug(): void
{
$this->testLogLevel(LogLevel::DEBUG);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsInfoMessageWhenLevelIsInfo(): void
{
$this->testLogLevel(LogLevel::INFO);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsWarningMessageWhenLevelIsWarning(): void
{
$this->testLogLevel(LogLevel::WARNING);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsErrorMessageWhenLevelIsError(): void
{
$this->testLogLevel(LogLevel::ERROR);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsCriticalMessageWhenLevelIsCritical(): void
{
$this->testLogLevel(LogLevel::CRITICAL);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsAlertMessageWhenLevelIsAlert(): void
{
$this->testLogLevel(LogLevel::ALERT);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsEmergencyMessageWhenLevelIsEmergency(): void
{
$this->testLogLevel(LogLevel::EMERGENCY);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsNoticeMessageWhenLevelIsNotice(): void
{
$this->testLogLevel(LogLevel::NOTICE);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsInfo(): void
{
$this->testLogLevelEmpty(LogLevel::INFO, LogLevel::DEBUG);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsWarning(): void
{
$this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::INFO);
$this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::DEBUG);
}
/**
* @throws NotFoundExceptionInterface
* @throws ContainerExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsError(): void
{
$this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::DEBUG);
@@ -100,12 +158,20 @@ class LoggerTest extends Unit
$this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::WARNING);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testDoesNotLogWhenMinimumLevelIsNotice(): void
{
$this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::DEBUG);
$this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::INFO);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsMessageWithEmptyContext(): void
{
[$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO);
@@ -118,6 +184,10 @@ class LoggerTest extends Unit
$this->assertEquals('Message without context', $decoded['message']);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsMessageWithComplexContext(): void
{
[$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO);
@@ -135,6 +205,10 @@ class LoggerTest extends Unit
$this->assertEquals('value', $decoded['context']['nested']['key']);
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function testLogsStringableMessage(): void
{
[$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO);

30
tests/Models/UserTest.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\Models;
use Siteworxpro\App\Models\User;
use Siteworxpro\Tests\Unit;
class UserTest extends Unit
{
public function testFormatsName(): void
{
$user = new User();
$user->first_name = 'John';
$user->last_name = 'Doe';
$this->assertEquals('John Doe', $user->full_name);
}
public function testFormatsEmail(): void
{
$user = new User();
$user->first_name = 'Jane';
$user->last_name = 'Smith';
$user->email = 'jane.smith@email.com';
$this->assertEquals('Jane Smith <jane.smith@email.com>', $user->formatted_email);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\Tests\ServiceProviders;
use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
class DispatcherServiceProviderTest extends AbstractServiceProvider
{
protected function getProviderClass(): string
{
return DispatcherServiceProvider::class;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Siteworxpro\Tests;
use Illuminate\Container\Container;
use Mockery;
use PHPUnit\Framework\TestCase;
use Siteworx\Config\Config as SWConfig;
use Siteworxpro\App\Services\Facade;
@@ -12,13 +13,25 @@ use Siteworxpro\App\Services\Facades\Config;
abstract class Unit extends TestCase
{
protected function getContainer(): Container
{
$container = Facade::getFacadeContainer();
if ($container === null) {
$container = new Container();
Facade::setFacadeContainer($container);
return $container;
}
return $container;
}
/**
* @throws \ReflectionException
*/
protected function setUp(): void
{
$container = new Container();
Facade::setFacadeContainer($container);
$container = $this->getContainer();
$container->bind(SWConfig::class, function () {
return SWConfig::load(__DIR__ . '/../config.php');
@@ -29,5 +42,6 @@ abstract class Unit extends TestCase
{
Config::clearResolvedInstances();
Facade::setFacadeContainer(null);
Mockery::close();
}
}