lthn.io/app/Core/Tests/Feature/SecurityFixesTest.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

556 lines
20 KiB
PHP

<?php
declare(strict_types=1);
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
/**
* Security fixes smoke tests.
*
* These tests verify the security fixes applied during the code review.
* Each test targets a specific vulnerability that was identified and fixed.
*/
use Core\Mod\Analytics\Models\AnalyticsEvent;
use Core\Mod\Analytics\Models\AnalyticsGoal;
use Core\Mod\Analytics\Models\AnalyticsWebsite;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway;
use Core\Mod\Commerce\View\Modal\Web\CheckoutCancel;
use Core\Mod\Commerce\View\Modal\Web\CheckoutSuccess;
use Core\Mod\Social\View\Modal\Admin\MediaPicker;
use Core\Mod\Social\View\Modal\Admin\TemplateIndex;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
});
describe('Checkout Authorization Fixes', function () {
describe('CheckoutSuccess authorization logic', function () {
it('denies access to orders belonging to other workspaces', function () {
// Create order for a different workspace
$otherWorkspace = Workspace::factory()->create();
$order = Order::create([
'workspace_id' => $otherWorkspace->id,
'order_number' => 'ORD-TEST-001',
'status' => 'paid', // Valid status
'subtotal' => 100.00,
'tax' => 20.00,
'total' => 120.00,
'currency' => 'GBP',
]);
// Test authorization via component mount logic
$component = new CheckoutSuccess;
// Use reflection to call protected authorizeOrder
Auth::login($this->user);
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('authorizeOrder');
$method->setAccessible(true);
$result = $method->invoke($component, $order);
// Authorization should FAIL - order belongs to different workspace
expect($result)->toBeFalse();
});
it('allows access to own workspace orders', function () {
$order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-TEST-002',
'status' => 'paid', // Valid status
'subtotal' => 100.00,
'tax' => 20.00,
'total' => 120.00,
'currency' => 'GBP',
]);
// Test authorization via component logic
$component = new CheckoutSuccess;
Auth::login($this->user);
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('authorizeOrder');
$method->setAccessible(true);
$result = $method->invoke($component, $order);
// Authorization should PASS - order belongs to user's workspace
expect($result)->toBeTrue();
});
});
describe('CheckoutCancel authorization logic', function () {
it('denies access to orders belonging to other workspaces', function () {
// Create order for a different workspace
$otherWorkspace = Workspace::factory()->create();
$order = Order::create([
'workspace_id' => $otherWorkspace->id,
'order_number' => 'ORD-CANCEL-001',
'status' => 'pending',
'subtotal' => 100.00,
'tax' => 20.00,
'total' => 120.00,
'currency' => 'GBP',
]);
// Test authorization via component logic
$component = new CheckoutCancel;
Auth::login($this->user);
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('authorizeOrder');
$method->setAccessible(true);
$result = $method->invoke($component, $order);
// Authorization should FAIL - order belongs to different workspace
expect($result)->toBeFalse();
});
});
});
describe('Analytics Goal ReDoS Fix', function () {
beforeEach(function () {
$this->website = AnalyticsWebsite::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'name' => 'Test Site',
'host' => 'example.com',
]);
});
it('safely handles regex metacharacters in goal paths', function () {
// This path contains regex metacharacters that could cause ReDoS
$goal = AnalyticsGoal::create([
'workspace_id' => $this->workspace->id,
'website_id' => $this->website->id,
'user_id' => $this->user->id,
'name' => 'Test Goal',
'type' => 'pageview',
'path' => '/product/.*+?^${}()|[]\\',
'is_enabled' => true,
]);
// Use reflection to test the protected method directly
$reflection = new ReflectionClass($goal);
$method = $reflection->getMethod('checkPageviewGoal');
$method->setAccessible(true);
// Create a matching event
$event = new AnalyticsEvent([
'website_id' => $this->website->id,
'type' => AnalyticsEvent::TYPE_PAGEVIEW,
'path' => '/product/.*+?^${}()|[]\\',
]);
// Should not throw and should match the literal path
$result = $method->invoke($goal, $event);
expect($result)->toBeBool();
});
it('still supports wildcards after escaping', function () {
$goal = AnalyticsGoal::create([
'workspace_id' => $this->workspace->id,
'website_id' => $this->website->id,
'user_id' => $this->user->id,
'name' => 'Wildcard Goal',
'type' => 'pageview',
'path' => '/products/*',
'is_enabled' => true,
]);
// Use reflection to test the protected method directly
$reflection = new ReflectionClass($goal);
$method = $reflection->getMethod('checkPageviewGoal');
$method->setAccessible(true);
// Create a matching event
$event = new AnalyticsEvent([
'website_id' => $this->website->id,
'type' => AnalyticsEvent::TYPE_PAGEVIEW,
'path' => '/products/widget-123',
]);
$result = $method->invoke($goal, $event);
expect($result)->toBeTrue();
});
it('prevents catastrophic backtracking patterns', function () {
// Create a goal with a path that, if not escaped, would cause ReDoS
$goal = AnalyticsGoal::create([
'workspace_id' => $this->workspace->id,
'website_id' => $this->website->id,
'user_id' => $this->user->id,
'name' => 'ReDoS Test Goal',
'type' => 'pageview',
'path' => '(a+)+$',
'is_enabled' => true,
]);
// Use reflection to test the protected method directly
$reflection = new ReflectionClass($goal);
$method = $reflection->getMethod('checkPageviewGoal');
$method->setAccessible(true);
$event = new AnalyticsEvent([
'website_id' => $this->website->id,
'type' => AnalyticsEvent::TYPE_PAGEVIEW,
'path' => str_repeat('a', 50).'b',
]);
// This should complete quickly (not hang) because the pattern is escaped
$start = microtime(true);
$result = $method->invoke($goal, $event);
$elapsed = microtime(true) - $start;
expect($elapsed)->toBeLessThan(1.0); // Should complete in under 1 second
});
});
describe('Analytics Goal Controller N+1 Fix', function () {
beforeEach(function () {
$this->website = AnalyticsWebsite::create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'name' => 'Test Site',
'host' => 'example.com',
]);
});
it('eager loads website relationship on show', function () {
// Skip if route not registered (Analytics API routes not wired up in Boot.php)
if (! Route::has('api.analytics.goals.show')) {
$this->markTestSkipped('Analytics goals API route not registered');
}
$goal = AnalyticsGoal::create([
'workspace_id' => $this->workspace->id,
'website_id' => $this->website->id,
'user_id' => $this->user->id,
'name' => 'Test Goal',
'type' => 'pageview',
'path' => '/test',
'is_enabled' => true,
]);
// Count queries
DB::enableQueryLog();
$response = $this->actingAs($this->user)
->getJson("/api/v1/analytics/goals/{$goal->id}");
$queries = DB::getQueryLog();
DB::disableQueryLog();
// Should be 200 OK
expect($response->status())->toBe(200);
// Should not have more than a reasonable number of queries
// (The fix ensures website is eager loaded, not queried separately)
expect(count($queries))->toBeLessThan(10);
});
});
describe('Commerce Controller Type Safety', function () {
it('handles package code lookup correctly in upgrade preview', function () {
// Create a test package
$package = Package::create([
'code' => 'test-upgrade-package',
'name' => 'Test Upgrade Package',
'description' => 'For testing upgrades',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 100,
]);
// Without an active subscription, we expect a 400 error
// but the important thing is it doesn't 500 due to type mismatch
$response = $this->actingAs($this->user)
->postJson('/api/v1/commerce/upgrade/preview', [
'package_code' => 'test-upgrade-package',
]);
// Should not be a 500 server error
expect($response->status())->not->toBe(500);
});
it('validates package_code exists in database', function () {
$response = $this->actingAs($this->user)
->postJson('/api/v1/commerce/upgrade/preview', [
'package_code' => 'nonexistent-package-xyz',
]);
// Should fail validation, not cause a server error
expect($response->status())->toBe(422);
});
});
describe('BTCPay Gateway Return Type', function () {
it('returns consistent array format from refund method on error', function () {
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'btcpay',
'gateway_payment_id' => 'btc_test_123',
'amount' => 100.00,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
// Create a gateway with mock config
config([
'commerce.gateways.btcpay.url' => 'https://pay.test.com',
'commerce.gateways.btcpay.store_id' => 'test_store',
'commerce.gateways.btcpay.api_key' => 'test_key',
'commerce.gateways.btcpay.webhook_secret' => 'test_webhook_secret',
]);
$gateway = new BTCPayGateway;
// Mock HTTP to fail so we test the error path
Http::fake([
'*' => Http::response(['error' => 'Test error'], 400),
]);
$result = $gateway->refund($payment, 50.00, 'Test refund');
// Should return array with expected keys
expect($result)->toBeArray()
->and($result)->toHaveKey('success')
->and($result['success'])->toBeFalse()
->and($result)->toHaveKey('error');
});
it('returns success array format when refund succeeds', function () {
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'gateway' => 'btcpay',
'gateway_payment_id' => 'btc_test_456',
'amount' => 100.00,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
// Create a gateway with mock config
config([
'commerce.gateways.btcpay.url' => 'https://pay.test.com',
'commerce.gateways.btcpay.store_id' => 'test_store',
'commerce.gateways.btcpay.api_key' => 'test_key',
'commerce.gateways.btcpay.webhook_secret' => 'test_webhook_secret',
]);
$gateway = new BTCPayGateway;
// Mock HTTP to succeed
Http::fake([
'*' => Http::response([
'id' => 'refund_123',
'status' => 'processed',
], 200),
]);
$result = $gateway->refund($payment, 50.00, 'Test refund');
// Should return array with expected keys
expect($result)->toBeArray()
->and($result)->toHaveKey('success')
->and($result['success'])->toBeTrue()
->and($result)->toHaveKey('refund_id');
});
});
describe('SocialPost Controller User Type Check', function () {
it('handles non-User instances gracefully in duplicate', function () {
// This is more of a unit test to verify the type check exists
// In real scenarios, $request->user() should always return User
// but we added the check as a safety measure
$response = $this->actingAs($this->user)
->postJson('/api/v1/social/posts/99999/duplicate');
// Should not cause a 500 error from type mismatch
// Expected 404 because post doesn't exist or 404 for no workspace
expect($response->status())->toBeIn([404, 403]);
});
});
describe('LIKE Wildcard Injection Fix', function () {
it('escapes LIKE wildcards in MediaPicker search', function () {
// Test the escapeLikeWildcards helper directly
$component = new MediaPicker;
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('escapeLikeWildcards');
$method->setAccessible(true);
// Test various injection attempts
expect($method->invoke($component, 'normal search'))->toBe('normal search')
->and($method->invoke($component, '%admin%'))->toBe('\\%admin\\%')
->and($method->invoke($component, '_secret_'))->toBe('\\_secret\\_')
->and($method->invoke($component, '% OR 1=1 --'))->toBe('\\% OR 1=1 --')
->and($method->invoke($component, '100%'))->toBe('100\\%')
->and($method->invoke($component, 'test_file'))->toBe('test\\_file');
});
it('does not affect normal search terms', function () {
$component = new MediaPicker;
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('escapeLikeWildcards');
$method->setAccessible(true);
// Normal searches should not be modified
expect($method->invoke($component, 'my image'))->toBe('my image')
->and($method->invoke($component, 'logo.png'))->toBe('logo.png')
->and($method->invoke($component, 'header-image-2024'))->toBe('header-image-2024');
});
});
describe('Null Workspace Checks', function () {
it('TemplateIndex handles null workspace gracefully', function () {
// Create a fresh user with no workspace association
$freshUser = User::factory()->create();
// Test without a default workspace - should not crash
Livewire::actingAs($freshUser)
->test(TemplateIndex::class)
->assertStatus(200);
});
it('user workspace method returns null for users without workspaces', function () {
// Create a fresh user with no workspace association
$freshUser = User::factory()->create();
// The defaultHostWorkspace method should return null, not throw
expect($freshUser->defaultHostWorkspace())->toBeNull();
});
});
describe('Entitlement Package Revocation', function () {
it('revokePackage marks package as cancelled', function () {
$package = Package::create([
'code' => 'test-revoke-pkg',
'name' => 'Test Revoke Package',
'description' => 'For testing revocation',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 100,
]);
$entitlements = app(EntitlementService::class);
$entitlements->provisionPackage($this->workspace, 'test-revoke-pkg');
// Verify package is active
$activePackage = $this->workspace->workspacePackages()
->whereHas('package', fn ($q) => $q->where('code', 'test-revoke-pkg'))
->active()
->first();
expect($activePackage)->not->toBeNull();
// Revoke the package
$entitlements->revokePackage($this->workspace, 'test-revoke-pkg');
// Verify package is now cancelled
$activePackage = $this->workspace->workspacePackages()
->whereHas('package', fn ($q) => $q->where('code', 'test-revoke-pkg'))
->active()
->first();
expect($activePackage)->toBeNull();
});
it('revokePackage handles non-existent packages gracefully', function () {
$entitlements = app(EntitlementService::class);
// Should not throw when revoking a package that doesn't exist
$entitlements->revokePackage($this->workspace, 'non-existent-package-xyz');
// No exception means success
expect(true)->toBeTrue();
});
});
describe('Checkout Edge Cases', function () {
it('CheckoutSuccess denies access when user has no workspace', function () {
$freshUser = User::factory()->create();
$order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-NO-USER-WS',
'status' => 'paid',
'subtotal' => 100.00,
'tax' => 20.00,
'total' => 120.00,
'currency' => 'GBP',
]);
$component = new CheckoutSuccess;
Auth::login($freshUser);
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('authorizeOrder');
$method->setAccessible(true);
$result = $method->invoke($component, $order);
// Should fail - user has no workspace
expect($result)->toBeFalse();
});
it('CheckoutCancel denies access when user has no workspace', function () {
$freshUser = User::factory()->create();
$order = Order::create([
'workspace_id' => $this->workspace->id,
'order_number' => 'ORD-NO-USER-WS-CANCEL',
'status' => 'pending',
'subtotal' => 100.00,
'tax' => 20.00,
'total' => 120.00,
'currency' => 'GBP',
]);
$component = new CheckoutCancel;
Auth::login($freshUser);
$reflection = new ReflectionClass($component);
$method = $reflection->getMethod('authorizeOrder');
$method->setAccessible(true);
$result = $method->invoke($component, $order);
// Should fail - user has no workspace
expect($result)->toBeFalse();
});
});