diff --git a/app/app/Console/Commands/IncidentsWatchFailures.php b/app/app/Console/Commands/IncidentsWatchFailures.php index ac4992b0..98743065 100644 --- a/app/app/Console/Commands/IncidentsWatchFailures.php +++ b/app/app/Console/Commands/IncidentsWatchFailures.php @@ -27,13 +27,13 @@ class IncidentsWatchFailures extends Command public function handle(): int { - $windowMinutes = (int) $this->option('window'); - $threshold = (int) $this->option('threshold'); - $dedupMinutes = (int) $this->option('dedup-window'); + $windowMinutes = (int) $this->option('window'); + $threshold = (int) $this->option('threshold'); + $dedupMinutes = (int) $this->option('dedup-window'); - $since = Carbon::now()->subMinutes($windowMinutes); + $since = Carbon::now()->subMinutes($windowMinutes); $dedupAt = Carbon::now()->subMinutes($dedupMinutes); - $now = Carbon::now(); + $now = Carbon::now(); // Группируем упавшие (ещё не resolved) джобы за окно по сигнатуре $groups = DB::table('failed_webhook_jobs') @@ -46,6 +46,7 @@ class IncidentsWatchFailures extends Command if ($groups->isEmpty()) { $this->info('No failure spikes detected.'); + return self::SUCCESS; } @@ -57,39 +58,41 @@ class IncidentsWatchFailures extends Command if ($adminId === null) { $this->error('No active saas_admin_users found — cannot create incidents_log rows.'); + return self::FAILURE; } $created = 0; foreach ($groups as $group) { - $sig = $group->sig; + $sig = $group->sig; $count = (int) $group->cnt; // Дедупликация: есть ли уже открытый инцидент с такой сигнатурой? $alreadyOpen = DB::table('incidents_log') - ->where('summary', 'like', '%' . addcslashes(substr($sig, 0, 80), '%_\\') . '%') + ->where('summary', 'like', '%'.addcslashes(substr($sig, 0, 80), '%_\\').'%') ->whereNull('resolved_at') ->where('detected_at', '>=', $dedupAt) ->exists(); if ($alreadyOpen) { $this->line("Skipping (dedup): {$sig}"); + continue; } DB::table('incidents_log')->insert([ - 'type' => 'other', - 'severity' => 'high', - 'summary' => "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. " - . "Сигнатура: {$sig}", - 'root_cause' => null, - 'started_at' => $since, - 'detected_at' => $now, - 'resolved_at' => null, + 'type' => 'other', + 'severity' => 'high', + 'summary' => "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. " + ."Сигнатура: {$sig}", + 'root_cause' => null, + 'started_at' => $since, + 'detected_at' => $now, + 'resolved_at' => null, 'created_by_admin_id' => $adminId, - 'created_at' => $now, - 'updated_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, ]); $created++; diff --git a/app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php b/app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php index 32deb06a..7284f526 100644 --- a/app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php +++ b/app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; -use App\Http\Controllers\Controller; use App\Http\Controllers\Concerns\ResolvesAdminUserId; +use App\Http\Controllers\Controller; use App\Jobs\Supplier\CsvReconcileJob; use App\Models\Project; use App\Models\SaasAdminAuditLog; @@ -148,13 +148,13 @@ final class AdminSupplierIntegrationController extends Controller ]); SaasAdminAuditLog::create([ - 'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'), - 'action' => 'supplier_integration.manual_queue_resolved', - 'target_type' => 'manual_queue_item', - 'target_id' => $row->id, - 'payload_after' => ['external_id' => $found, 'platform' => $row->platform], - 'ip_address' => $request->ip() ?? '127.0.0.1', - 'user_agent' => $request->userAgent(), + 'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'), + 'action' => 'supplier_integration.manual_queue_resolved', + 'target_type' => 'manual_queue_item', + 'target_id' => $row->id, + 'payload_after' => ['external_id' => $found, 'platform' => $row->platform], + 'ip_address' => $request->ip() ?? '127.0.0.1', + 'user_agent' => $request->userAgent(), 'requires_approval' => false, ]); @@ -184,14 +184,14 @@ final class AdminSupplierIntegrationController extends Controller ); SaasAdminAuditLog::create([ - 'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'), - 'action' => 'supplier_integration.export_mode_set', - 'target_type' => 'system_setting', - 'target_id' => null, - 'payload_before' => $prevMode !== null ? ['mode' => $prevMode] : null, - 'payload_after' => ['mode' => $data['mode']], - 'ip_address' => $request->ip() ?? '127.0.0.1', - 'user_agent' => $request->userAgent(), + 'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'), + 'action' => 'supplier_integration.export_mode_set', + 'target_type' => 'system_setting', + 'target_id' => null, + 'payload_before' => $prevMode !== null ? ['mode' => $prevMode] : null, + 'payload_after' => ['mode' => $data['mode']], + 'ip_address' => $request->ip() ?? '127.0.0.1', + 'user_agent' => $request->userAgent(), 'requires_approval' => false, ]); @@ -283,14 +283,14 @@ final class AdminSupplierIntegrationController extends Controller } SaasAdminAuditLog::create([ - 'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'), - 'action' => 'supplier_integration.projects_destroyed', - 'target_type' => 'supplier_projects_bulk', - 'target_id' => null, - 'payload_before' => ['ids' => $data['ids']], - 'payload_after' => ['deleted' => $deleted, 'failures_count' => count($failures)], - 'ip_address' => $request->ip() ?? '127.0.0.1', - 'user_agent' => $request->userAgent(), + 'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'), + 'action' => 'supplier_integration.projects_destroyed', + 'target_type' => 'supplier_projects_bulk', + 'target_id' => null, + 'payload_before' => ['ids' => $data['ids']], + 'payload_after' => ['deleted' => $deleted, 'failures_count' => count($failures)], + 'ip_address' => $request->ip() ?? '127.0.0.1', + 'user_agent' => $request->userAgent(), 'requires_approval' => false, ]); diff --git a/app/app/Http/Controllers/Api/SupplierWebhookController.php b/app/app/Http/Controllers/Api/SupplierWebhookController.php index 838247f8..93df1129 100644 --- a/app/app/Http/Controllers/Api/SupplierWebhookController.php +++ b/app/app/Http/Controllers/Api/SupplierWebhookController.php @@ -132,13 +132,13 @@ class SupplierWebhookController extends Controller try { DB::table('webhook_log')->insert([ - 'tenant_id' => null, + 'tenant_id' => null, 'raw_payload' => '{}', - 'source' => 'supplier', - 'status' => $status, - 'lead_id' => $leadId, - 'ip_address' => $request->ip(), - 'created_at' => now(), + 'source' => 'supplier', + 'status' => $status, + 'lead_id' => $leadId, + 'ip_address' => $request->ip(), + 'created_at' => now(), ]); } catch (\Throwable) { // Never let logging failure break the primary response diff --git a/app/app/Services/Audit/OperationsLogger.php b/app/app/Services/Audit/OperationsLogger.php index 237436fb..f7fc5e9f 100644 --- a/app/app/Services/Audit/OperationsLogger.php +++ b/app/app/Services/Audit/OperationsLogger.php @@ -1,4 +1,6 @@ -all(); // Snapshot ДО удаления — после delete() модель недоступна. - $tenantId = $project->tenant_id; + $tenantId = $project->tenant_id; $projectId = $project->id; - $snapshot = $project->toArray(); + $snapshot = $project->toArray(); $project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные. @@ -241,7 +241,7 @@ class ProjectService userId: auth()->id(), entityType: 'project', entityId: null, - event: 'project.bulk_' . $action, + event: 'project.bulk_'.$action, payloadBefore: null, payloadAfter: array_merge(['ids' => $ids], $result), ip: request()->ip(), diff --git a/app/database/migrations/2026_05_22_000001_tenant_operations_log.php b/app/database/migrations/2026_05_22_000001_tenant_operations_log.php index f81b5e57..c554c311 100644 --- a/app/database/migrations/2026_05_22_000001_tenant_operations_log.php +++ b/app/database/migrations/2026_05_22_000001_tenant_operations_log.php @@ -9,7 +9,7 @@ return new class extends Migration { $sql = file_get_contents(base_path('../db/migrations/2026_05_22_001_tenant_operations_log.sql')); if ($sql === false) { - throw new \RuntimeException('Migration SQL file not found.'); + throw new RuntimeException('Migration SQL file not found.'); } DB::unprepared($sql); } diff --git a/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php b/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php index 06f7f9dc..bd50e7c6 100644 --- a/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php +++ b/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php @@ -10,7 +10,7 @@ return new class extends Migration public function up(): void { // Guard: only run if webhook_log exists (should always exist, but be safe) - if (! \Schema::hasTable('webhook_log')) { + if (! Schema::hasTable('webhook_log')) { return; } @@ -23,7 +23,7 @@ return new class extends Migration public function down(): void { - if (! \Schema::hasTable('webhook_log')) { + if (! Schema::hasTable('webhook_log')) { return; } diff --git a/app/tests/Feature/Admin/SupplierIntegrationAuditTest.php b/app/tests/Feature/Admin/SupplierIntegrationAuditTest.php index ec7564aa..fe42d1df 100644 --- a/app/tests/Feature/Admin/SupplierIntegrationAuditTest.php +++ b/app/tests/Feature/Admin/SupplierIntegrationAuditTest.php @@ -24,12 +24,12 @@ function stubAdminUser(string $email = 'audit-stub@test.local'): int } return (int) DB::table('saas_admin_users')->insertGetId([ - 'email' => $email, - 'full_name' => 'Audit Test Stub', + 'email' => $email, + 'full_name' => 'Audit Test Stub', 'password_hash' => '$2y$04$system-stub-not-loginable', - 'role' => 'super_admin', - 'is_active' => false, - 'sso_provider' => 'local', + 'role' => 'super_admin', + 'is_active' => false, + 'sso_provider' => 'local', 'is_break_glass' => false, ]); } @@ -46,7 +46,7 @@ it('setExportMode writes audit log row', function (): void { $countBefore = DB::table('saas_admin_audit_log')->count(); $this->postJson('/api/admin/supplier-integration/export-mode', [ - 'mode' => 'online', + 'mode' => 'online', 'admin_user_id' => $adminId, ])->assertOk(); @@ -67,24 +67,30 @@ it('setExportMode writes audit log row', function (): void { it('manualQueueResolve writes audit log row', function (): void { $adminId = stubAdminUser(); - $admin = User::factory()->create(); + $admin = User::factory()->create(); $this->actingAs($admin); - $tenant = Tenant::factory()->create(); + $tenant = Tenant::factory()->create(); $project = Project::factory()->for($tenant)->create(); $queueRow = SupplierManualSyncQueue::create([ - 'project_id' => $project->id, - 'platform' => 'B1', - 'operation' => 'create', + 'project_id' => $project->id, + 'platform' => 'B1', + 'operation' => 'create', 'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'audit-test.ru'], - 'failure_reason' => 'contract_break', - 'status' => 'pending', + 'failure_reason' => 'contract_break', + 'status' => 'pending', ]); - $channelMock = new class implements SupplierProjectChannel { - public function createProject(SupplierProjectDto $dto): int { return 0; } + $channelMock = new class implements SupplierProjectChannel + { + public function createProject(SupplierProjectDto $dto): int + { + return 0; + } + public function updateProject(int $externalId, SupplierProjectDto $dto): void {} + public function listProjects(): array { return [['id' => 77777, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'audit-test.ru']]; @@ -114,28 +120,30 @@ it('projectsDestroy writes audit log row', function (): void { $adminId = stubAdminUser(); $this->actingAs(User::factory()->create()); - $clientMock = new class extends SupplierPortalClient { + $clientMock = new class extends SupplierPortalClient + { public function __construct() {} + public function deleteProject(int $externalId): void {} }; app()->instance(SupplierPortalClient::class, $clientMock); $sp = SupplierProject::query()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'audit-destroy.ru', - 'subject_code' => 77, - 'current_limit' => 0, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], - 'current_regions' => null, - 'sync_status' => 'ok', + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'audit-destroy.ru', + 'subject_code' => 77, + 'current_limit' => 0, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => null, + 'sync_status' => 'ok', 'supplier_external_id' => '12345', ]); $countBefore = DB::table('saas_admin_audit_log')->count(); $this->postJson('/api/admin/supplier-integration/projects/delete', [ - 'ids' => [$sp->id], + 'ids' => [$sp->id], 'admin_user_id' => $adminId, ])->assertOk()->assertJson(['deleted' => 1, 'failures' => []]); diff --git a/app/tests/Feature/Audit/OperationalFullFlowTest.php b/app/tests/Feature/Audit/OperationalFullFlowTest.php index ec8002c7..397accec 100644 --- a/app/tests/Feature/Audit/OperationalFullFlowTest.php +++ b/app/tests/Feature/Audit/OperationalFullFlowTest.php @@ -29,21 +29,21 @@ it('full operational flow produces rows in all four audit tables', function (): // ── Shared fixtures ────────────────────────────────────────────────────── $tenant = Tenant::factory()->create(); - $user = User::factory()->create(['tenant_id' => $tenant->id]); + $user = User::factory()->create(['tenant_id' => $tenant->id]); - DB::statement('SET app.current_tenant_id = ' . $tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); // Ensure a saas_admin_users row exists for saas_admin_audit_log FK. // Must be is_active=true so incidents:watch-failures can resolve the FK too. $adminId = DB::table('saas_admin_users')->where('email', 'opflow-stub@test.local')->value('id'); if ($adminId === null) { $adminId = (int) DB::table('saas_admin_users')->insertGetId([ - 'email' => 'opflow-stub@test.local', - 'full_name' => 'OpFlow Test Stub', - 'password_hash' => '$2y$04$system-stub-not-loginable', - 'role' => 'super_admin', - 'is_active' => true, - 'sso_provider' => 'local', + 'email' => 'opflow-stub@test.local', + 'full_name' => 'OpFlow Test Stub', + 'password_hash' => '$2y$04$system-stub-not-loginable', + 'role' => 'super_admin', + 'is_active' => true, + 'sso_provider' => 'local', 'is_break_glass' => false, ]); } @@ -51,11 +51,11 @@ it('full operational flow produces rows in all four audit tables', function (): // ── Step 1: Create a project → project.created in tenant_operations_log ── $this->actingAs($user)->postJson('/api/projects', [ - 'name' => 'Опфлоу тест', - 'signal_type' => 'site', - 'signal_identifier' => 'opflow-test.ru', + 'name' => 'Опфлоу тест', + 'signal_type' => 'site', + 'signal_identifier' => 'opflow-test.ru', 'daily_limit_target' => 30, - 'regions' => [], + 'regions' => [], 'delivery_days_mask' => 127, ])->assertStatus(201); @@ -105,8 +105,8 @@ it('full operational flow produces rows in all four audit tables', function (): // ── Step 4: Change webhook URL → webhook_settings.updated in tenant_operations_log $oldUrl = 'https://8.8.8.8/hook'; OutboundWebhookSubscription::factory()->create([ - 'tenant_id' => $tenant->id, - 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, 'target_url' => $oldUrl, ]); @@ -122,7 +122,7 @@ it('full operational flow produces rows in all four audit tables', function (): ->first(); expect($webhookSettingsRow)->not->toBeNull('webhook_settings.updated must be logged'); - $wsAfter = json_decode($webhookSettingsRow->payload_after, true); + $wsAfter = json_decode($webhookSettingsRow->payload_after, true); $wsBefore = json_decode($webhookSettingsRow->payload_before, true); expect($wsAfter['target_url'] ?? null)->toBe($newUrl); expect($wsBefore['target_url'] ?? null)->toBe($oldUrl); @@ -143,7 +143,7 @@ it('full operational flow produces rows in all four audit tables', function (): $this->actingAs(User::factory()->create()) ->postJson('/api/admin/supplier-integration/export-mode', [ - 'mode' => 'online', + 'mode' => 'online', 'admin_user_id' => $adminId, ])->assertOk(); @@ -166,10 +166,10 @@ it('full operational flow produces rows in all four audit tables', function (): $webhookLogCountBefore = DB::table('webhook_log')->count(); $this->postJson('/api/webhook/supplier/wrong-secret-here', [ - 'vid' => 999901, + 'vid' => 999901, 'project' => 'B1_opflow-test.ru', - 'phone' => '79991234567', - 'time' => time(), + 'phone' => '79991234567', + 'time' => time(), ])->assertStatus(404); expect(DB::table('webhook_log')->count())->toBe($webhookLogCountBefore + 1); @@ -189,8 +189,8 @@ it('full operational flow produces rows in all four audit tables', function (): $rows = []; for ($i = 0; $i < 201; $i++) { $rows[] = [ - 'failed_at' => $now, - 'exception' => 'App\\Exceptions\\WebhookException: opflow-storm', + 'failed_at' => $now, + 'exception' => 'App\\Exceptions\\WebhookException: opflow-storm', 'raw_payload' => '{}', 'retry_count' => 0, ]; diff --git a/app/tests/Feature/Console/IncidentsWatchFailuresTest.php b/app/tests/Feature/Console/IncidentsWatchFailuresTest.php index 2706e42b..0cbf2ace 100644 --- a/app/tests/Feature/Console/IncidentsWatchFailuresTest.php +++ b/app/tests/Feature/Console/IncidentsWatchFailuresTest.php @@ -9,13 +9,13 @@ use Illuminate\Support\Facades\DB; uses(DatabaseTransactions::class); // Helper: insert a failed_webhook_jobs row -function makeFailedWebhookJob(string $exception, ?\DateTimeInterface $at = null): void +function makeFailedWebhookJob(string $exception, ?DateTimeInterface $at = null): void { DB::table('failed_webhook_jobs')->insert([ - 'failed_at' => $at ?? now(), - 'exception' => $exception, - 'raw_payload' => '{}', - 'retry_count' => 0, + 'failed_at' => $at ?? now(), + 'exception' => $exception, + 'raw_payload' => '{}', + 'retry_count' => 0, ]); } @@ -28,12 +28,12 @@ function ensureSystemAdmin(): int } return (int) DB::table('saas_admin_users')->insertGetId([ - 'email' => 'system-cron@liderra.ru', - 'full_name' => 'System Cron', + 'email' => 'system-cron@liderra.ru', + 'full_name' => 'System Cron', 'password_hash' => '$2y$12$placeholder', - 'role' => 'dev_oncall', - 'is_active' => true, - 'created_at' => now(), + 'role' => 'dev_oncall', + 'is_active' => true, + 'created_at' => now(), ]); } diff --git a/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php index e1b6ff8a..34f39fae 100644 --- a/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php +++ b/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php @@ -21,10 +21,10 @@ it('logs status=received when lead is accepted (202)', function () { Bus::fake(); $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 900001, + 'vid' => 900001, 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), + 'phone' => '79991234567', + 'time' => time(), ])->assertStatus(202); $log = DB::table('webhook_log') @@ -41,10 +41,10 @@ it('logs status=received when lead is accepted (202)', function () { it('logs status=rejected_secret when secret is wrong (404)', function () { $this->postJson('/api/webhook/supplier/wrong-secret-here', [ - 'vid' => 900002, + 'vid' => 900002, 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), + 'phone' => '79991234567', + 'time' => time(), ])->assertStatus(404); $log = DB::table('webhook_log') @@ -64,10 +64,10 @@ it('logs status=rejected_ip when IP is not in allowlist (404)', function () { $this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8']) ->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 900003, + 'vid' => 900003, 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), + 'phone' => '79991234567', + 'time' => time(), ])->assertStatus(404); $log = DB::table('webhook_log') @@ -91,10 +91,10 @@ it('logs status=rate_limited when per-IP rate limit exceeded (429)', function () } $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 900004, + 'vid' => 900004, 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), + 'phone' => '79991234567', + 'time' => time(), ])->assertStatus(429); $log = DB::table('webhook_log') diff --git a/app/tests/Feature/Projects/ProjectMutationsAuditTest.php b/app/tests/Feature/Projects/ProjectMutationsAuditTest.php index 369dccda..8e4c2b74 100644 --- a/app/tests/Feature/Projects/ProjectMutationsAuditTest.php +++ b/app/tests/Feature/Projects/ProjectMutationsAuditTest.php @@ -5,27 +5,28 @@ declare(strict_types=1); use App\Models\Project; use App\Models\Tenant; use App\Models\User; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; -uses(\Illuminate\Foundation\Testing\DatabaseTransactions::class); +uses(DatabaseTransactions::class); beforeEach(function () { Queue::fake(); $this->tenant = Tenant::factory()->create(); - $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); - DB::statement('SET app.current_tenant_id = ' . $this->tenant->id); + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); }); it('logs project.created when a project is stored', function () { $this->actingAs($this->user)->postJson('/api/projects', [ - 'name' => 'Окна СПб', - 'signal_type' => 'site', - 'signal_identifier' => 'okna-spb.ru', + 'name' => 'Окна СПб', + 'signal_type' => 'site', + 'signal_identifier' => 'okna-spb.ru', 'daily_limit_target' => 50, - 'regions' => [], + 'regions' => [], 'delivery_days_mask' => 127, ])->assertStatus(201); @@ -40,7 +41,7 @@ it('logs project.created when a project is stored', function () { it('logs project.updated with before/after diff when a project is patched', function () { $project = Project::factory()->create([ - 'tenant_id' => $this->tenant->id, + 'tenant_id' => $this->tenant->id, 'daily_limit_target' => 10, ]); @@ -57,7 +58,7 @@ it('logs project.updated with before/after diff when a project is patched', func expect($row)->not->toBeNull(); $before = json_decode($row->payload_before, true); - $after = json_decode($row->payload_after, true); + $after = json_decode($row->payload_after, true); expect($before)->toHaveKey('daily_limit_target'); expect($after)->toHaveKey('daily_limit_target'); @@ -86,7 +87,7 @@ it('logs project.bulk_ when a bulk action is executed', function () { $this->actingAs($this->user)->postJson('/api/projects/bulk', [ 'action' => 'pause', - 'ids' => [$p1->id, $p2->id], + 'ids' => [$p1->id, $p2->id], ])->assertOk()->assertJsonPath('updated', 2); $row = DB::table('tenant_operations_log') diff --git a/app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php b/app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php index 6530c729..9449ed2b 100644 --- a/app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php +++ b/app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php @@ -1,4 +1,6 @@ -create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); - DB::statement('SET app.current_tenant_id = ' . $tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $r = $this->postJson('/api/api-keys/regenerate'); $r->assertStatus(201); diff --git a/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php b/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php index 70cec2b1..43cba6e7 100644 --- a/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php +++ b/app/tests/Feature/Security/WebhookUrlChangeAuditTest.php @@ -1,4 +1,6 @@ -create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); - DB::statement('SET app.current_tenant_id = ' . $tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $oldUrl = 'https://8.8.8.8/hook'; OutboundWebhookSubscription::factory()->create([ @@ -48,7 +50,7 @@ it('webhook_settings.updated logged when subscription created for first time', f $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); - DB::statement('SET app.current_tenant_id = ' . $tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $newUrl = 'https://93.184.216.34/hook'; $r = $this->putJson('/api/tenants/me/webhook-settings', [ @@ -76,7 +78,7 @@ it('audit log does not contain webhook secret', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); - DB::statement('SET app.current_tenant_id = ' . $tenant->id); + DB::statement('SET app.current_tenant_id = '.$tenant->id); $r = $this->putJson('/api/tenants/me/webhook-settings', [ 'target_url' => 'https://93.184.216.34/hook', diff --git a/app/tests/Unit/Services/Audit/OperationsLoggerTest.php b/app/tests/Unit/Services/Audit/OperationsLoggerTest.php index 4775e82d..57041655 100644 --- a/app/tests/Unit/Services/Audit/OperationsLoggerTest.php +++ b/app/tests/Unit/Services/Audit/OperationsLoggerTest.php @@ -1,8 +1,11 @@ -latest('id')->value('id'); expect(fn () => DB::table('tenant_operations_log')->where('id', $id)->update(['event' => 'x'])) - ->toThrow(\Illuminate\Database\QueryException::class); + ->toThrow(QueryException::class); }); diff --git a/docs/superpowers/plans/2026-05-22-audit-operational.md b/docs/superpowers/plans/2026-05-22-audit-operational.md index 6aaf8d52..219fbb3e 100644 --- a/docs/superpowers/plans/2026-05-22-audit-operational.md +++ b/docs/superpowers/plans/2026-05-22-audit-operational.md @@ -1,5 +1,7 @@ # P2 — Operational journaling (projects / API keys / webhook URL / admin-supplier / incidents auto) +> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p2-operational` (от P0+P1 base). 11 commits (Tasks 1-9 + gate). New schema v8.28/v8.29 (`tenant_operations_log` table + 2 indexes + 1 RLS + 2 hash-chain триггера; `webhook_log` ALTER +5 колонок). Все 4 ветки `tenant_operations_log` пишутся: project.created/updated/deleted/bulk_, api_key.regenerated (key_prefix only — no plain key), webhook_settings.updated. Admin actions (export_mode_set / manual_queue_resolved / projects_destroyed) → `saas_admin_audit_log`. SupplierWebhook → `webhook_log` (received/rejected_secret/rejected_ip/rate_limited). Cron `incidents:watch-failures` каждые 10 мин → `incidents_log` на failure-spike (threshold 200/окно, дедуп 60 мин). Touched-area regression **67/67 passing** (275 assertions); Pint **clean**; Larastan **production code clean** (0 real findings, 29 Pest TestCall false-positives в новых тестах — environmental, same pattern as P0/P1). NB: `--parallel` full-suite не запущен — targeted sequential regression substitute. + > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`). **Goal:** Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и **авто-наполнение `incidents_log`** на основе порога падений (решение D=a: cron-watcher).