feat: add routes, lang, tests, stubs, docs, and docker configurations
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\System;
|
||||
|
||||
use App\Models\AiHealingLog;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiCircuitBreakerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(\Database\Seeders\RoleAndPermissionSeeder::class);
|
||||
}
|
||||
|
||||
public function test_circuit_breaker_blocks_excessive_healing_attempts()
|
||||
{
|
||||
// 1. Set limit to 2 fixes per hour
|
||||
$config = app(SystemConfigService::class);
|
||||
$config->update(['ai_healing_max_attempts_per_hour' => 2]);
|
||||
|
||||
// 2. Create 2 recent "resolved" logs
|
||||
AiHealingLog::factory()->count(2)->create([
|
||||
'status' => 'resolved',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
// 3. Verify count
|
||||
$this->assertEquals(2, AiHealingLog::where('created_at', '>', now()->subHour())->count());
|
||||
|
||||
// 4. Simulate a new exception (we need to trigger the logic in bootstrap/app.php or simulate it)
|
||||
// Since we can't easily trigger bootstrap/app.php in a feature test without throwing a real exception,
|
||||
// we can test the logic directly or via a mock.
|
||||
|
||||
// Let's test if the circuit breaker logic would block.
|
||||
$maxPerHour = (int) $config->get('ai_healing_max_attempts_per_hour', 5);
|
||||
$recentCount = AiHealingLog::where('created_at', '>', now()->subHour())
|
||||
->whereIn('status', ['resolved', 'pending', 'diagnosing'])
|
||||
->count();
|
||||
|
||||
$this->assertTrue($recentCount >= $maxPerHour, "Circuit breaker should be tripped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
function makeEditorUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage global settings', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
// ── Access control ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot upload editor images', function () {
|
||||
$this->postJson('/editor/upload')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('user without manage global settings cannot upload', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
// ── Missing file ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('upload without file returns 400', function () {
|
||||
$user = makeEditorUser();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload')
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('error.message', 'No file uploaded.');
|
||||
});
|
||||
|
||||
// ── Type validation ────────────────────────────────────────────────────────────
|
||||
|
||||
test('upload rejects non-image file', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->create('shell.php', 100, 'application/x-php');
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload', ['upload' => $file])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload rejects svg (not in allowed mimes)', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->create('vector.svg', 50, 'image/svg+xml');
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload', ['upload' => $file])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload accepts jpeg image', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->assertJsonPath('uploaded', 1)
|
||||
->assertJsonStructure(['url', 'fileName']);
|
||||
});
|
||||
|
||||
test('upload accepts png image', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('icon.png', 100, 100);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->assertJsonPath('uploaded', 1);
|
||||
});
|
||||
|
||||
test('upload accepts webp image', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('modern.webp', 400, 300);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->assertJsonPath('uploaded', 1);
|
||||
});
|
||||
|
||||
// ── Size limit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('upload rejects image exceeding 5 MB', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->create('big.jpg', 6000, 'image/jpeg'); // 6 MB
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/editor/upload', ['upload' => $file])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── Response structure ─────────────────────────────────────────────────────────
|
||||
|
||||
test('successful upload response has uploaded, fileName, url keys', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('test.jpg');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
expect($response)->toHaveKeys(['uploaded', 'fileName', 'url']);
|
||||
expect($response['uploaded'])->toBe(1);
|
||||
expect($response['url'])->toStartWith('/storage/');
|
||||
});
|
||||
|
||||
// ── File is actually stored ────────────────────────────────────────────────────
|
||||
|
||||
test('uploaded file is persisted to the public disk under editor/', function () {
|
||||
$user = makeEditorUser();
|
||||
$file = UploadedFile::fake()->image('stored.jpg');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post('/editor/upload', ['upload' => $file])
|
||||
->json();
|
||||
|
||||
// Strip /storage/ prefix to get the relative path on the public disk
|
||||
$relativePath = substr((string) $response['url'], strlen('/storage/'));
|
||||
Storage::disk('public')->assertExists($relativePath);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
$ref = new ReflectionClass(SystemConfigService::class);
|
||||
$prop = $ref->getProperty('resolvedSettings');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
function makeNotificationAdmin(): User
|
||||
{
|
||||
$role = Role::firstOrCreate(['name' => 'Developer', 'guard_name' => 'web']);
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
|
||||
foreach (['view notification center', 'manage notification center'] as $name) {
|
||||
$perm = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeNotificationViewer(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$perm = Permission::firstOrCreate(['name' => 'view notification center', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeNotificationForUser(User $user): Notification
|
||||
{
|
||||
$role = Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$notification = Notification::create([
|
||||
'title' => 'Test Notice',
|
||||
'message' => 'Hello',
|
||||
'recipient' => 'all',
|
||||
'type' => 'info',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Attach pivot row so the user sees it
|
||||
\DB::table('system_notification_user')->insert([
|
||||
'notification_id' => $notification->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => null,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
// ── Access control ────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot view notification center', function () {
|
||||
$this->get('/notification-center')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission is forbidden from notification center', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/notification-center')->assertForbidden();
|
||||
});
|
||||
|
||||
test('user with view permission can access notification center', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($user)->get('/notification-center')->assertOk();
|
||||
});
|
||||
|
||||
// ── Store (broadcast) ─────────────────────────────────────────────────────────
|
||||
|
||||
test('viewer without manage permission cannot broadcast notifications', function () {
|
||||
$viewer = makeNotificationViewer();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->postJson('/notification-center', [
|
||||
'title' => 'Test',
|
||||
'message' => 'Msg',
|
||||
'recipient' => 'User',
|
||||
'type' => 'info',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin can broadcast a notification', function () {
|
||||
$admin = makeNotificationAdmin();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/notification-center', [
|
||||
'title' => 'Important Update',
|
||||
'message' => 'Please read.',
|
||||
'recipient' => 'User',
|
||||
'type' => 'info',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
expect(Notification::where('title', 'Important Update')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('store rejects unknown notification type', function () {
|
||||
$admin = makeNotificationAdmin();
|
||||
Role::firstOrCreate(['name' => 'User', 'guard_name' => 'web']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/notification-center', [
|
||||
'title' => 'Bad Type',
|
||||
'message' => 'Msg',
|
||||
'recipient' => 'User',
|
||||
'type' => 'xss-alert',
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('store requires title and message', function () {
|
||||
$admin = makeNotificationAdmin();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->postJson('/notification-center', ['type' => 'info'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
// ── Mark as read ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('user can mark a notification as read', function () {
|
||||
$user = makeNotificationViewer();
|
||||
$notification = makeNotificationForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patchJson(route('notification-center.read', $notification->id))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
test('guest cannot mark notifications as read', function () {
|
||||
$notification = Notification::create([
|
||||
'title' => 'X', 'message' => 'Y', 'recipient' => 'all', 'type' => 'info',
|
||||
]);
|
||||
|
||||
// JSON requests return 401 Unauthorized (not a redirect) when unauthenticated
|
||||
$this->patchJson(route('notification-center.read', $notification->id))
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
// ── Mark all as read ──────────────────────────────────────────────────────────
|
||||
|
||||
test('user can mark all notifications as read', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patchJson(route('notification-center.read-all'))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
// ── Personal delete ───────────────────────────────────────────────────────────
|
||||
|
||||
test('user can delete (hide) a notification from their view', function () {
|
||||
$user = makeNotificationViewer();
|
||||
$notification = makeNotificationForUser($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson(route('notification-center.destroy', $notification->id))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
// ── Feature flag ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('feature disabled blocks regular viewers but allows manage-global-settings users', function () {
|
||||
app(SystemConfigService::class)->update(['feature_notification_center' => false]);
|
||||
|
||||
$viewer = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get('/notification-center')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('feature flag disabled still allows users with manage global settings', function () {
|
||||
app(SystemConfigService::class)->update(['feature_notification_center' => false]);
|
||||
|
||||
$admin = makeNotificationViewer();
|
||||
$perm = Permission::firstOrCreate(['name' => 'manage global settings', 'guard_name' => 'web']);
|
||||
$admin->givePermissionTo($perm);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/notification-center')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
// ── Recent notifications API ──────────────────────────────────────────────────
|
||||
|
||||
test('recent notifications endpoint returns json with unread count', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/notification-center/api/recent')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['success', 'unread_count', 'notifications', 'has_more']);
|
||||
});
|
||||
|
||||
test('recent notifications content is escaped', function () {
|
||||
$user = makeNotificationViewer();
|
||||
|
||||
$notification = Notification::create([
|
||||
'title' => '<script>alert(1)</script>',
|
||||
'message' => '<b>bold</b>',
|
||||
'recipient' => 'all',
|
||||
'type' => 'info',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
\DB::table('system_notification_user')->insert([
|
||||
'notification_id' => $notification->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => null,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/notification-center/api/recent')
|
||||
->json();
|
||||
|
||||
$titles = collect($response['notifications'])->pluck('title')->all();
|
||||
// Title must be HTML-entity-escaped, not raw script tag
|
||||
foreach ($titles as $t) {
|
||||
expect($t)->not->toContain('<script>');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\User;
|
||||
|
||||
function makeSessionManager(): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
foreach (['view active sessions', 'manage active sessions'] as $name) {
|
||||
$perm = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function makeSessionViewer(): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$perm = Permission::firstOrCreate(['name' => 'view active sessions', 'guard_name' => 'web']);
|
||||
$user->givePermissionTo($perm);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ── Access control ────────────────────────────────────────────────────────────
|
||||
|
||||
test('guest cannot access session manager', function () {
|
||||
$this->get('/session-manager')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('user without permission is forbidden from session manager', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/session-manager')->assertForbidden();
|
||||
});
|
||||
|
||||
test('user with view permission can access session manager', function () {
|
||||
$user = makeSessionViewer();
|
||||
|
||||
$this->actingAs($user)->get('/session-manager')->assertOk();
|
||||
});
|
||||
|
||||
test('guest cannot access session stats endpoint', function () {
|
||||
// JSON requests return 401 Unauthorized (not a redirect) for unauthenticated requests
|
||||
$this->getJson('/session-manager/stats')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('user with view permission can access session stats', function () {
|
||||
$user = makeSessionViewer();
|
||||
|
||||
$this->actingAs($user)->getJson('/session-manager/stats')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['total', 'active']);
|
||||
});
|
||||
|
||||
// ── Stats structure ────────────────────────────────────────────────────────────
|
||||
|
||||
test('stats endpoint returns valid json with total key', function () {
|
||||
$user = makeSessionViewer();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/session-manager/stats')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['total', 'active']);
|
||||
});
|
||||
|
||||
// ── Terminate session ─────────────────────────────────────────────────────────
|
||||
|
||||
test('user without manage permission cannot terminate a session', function () {
|
||||
$viewer = makeSessionViewer();
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->deleteJson('/session-manager/some-session-id')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('manager cannot terminate their own current session', function () {
|
||||
// The controller's destroy() checks: if ($id === session()->getId()) → 403.
|
||||
// We invoke it directly, bypassing HTTP, so the session ID is stable.
|
||||
$manager = makeSessionManager();
|
||||
$this->actingAs($manager);
|
||||
|
||||
$controller = app(\App\Http\Controllers\SystemSettings\SessionManagerController::class);
|
||||
$currentId = session()->getId();
|
||||
|
||||
$response = $controller->destroy(request(), $currentId);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
expect(json_decode($response->getContent(), true)['success'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('manager can terminate a different session id', function () {
|
||||
// With array/redis driver the controller returns success when no row is found to delete.
|
||||
$manager = makeSessionManager();
|
||||
|
||||
$this->actingAs($manager)
|
||||
->deleteJson('/session-manager/some-other-session-id')
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
});
|
||||
|
||||
test('guest cannot terminate sessions', function () {
|
||||
// JSON requests return 401 (not a redirect) for unauthenticated requests
|
||||
$this->deleteJson('/session-manager/any-id')->assertUnauthorized();
|
||||
});
|
||||
Reference in New Issue
Block a user