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>
556 lines
20 KiB
PHP
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();
|
|
});
|
|
});
|