style+done(p2): pint formatting + P2 plan DONE marker
This commit is contained in:
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php declare(strict_types=1);
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use Illuminate\Support\Facades\DB;
|
||||
class ProjectService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationsLogger $ops = new OperationsLogger(),
|
||||
private readonly OperationsLogger $ops = new OperationsLogger,
|
||||
) {}
|
||||
|
||||
public function update(Project $project, array $data): Project
|
||||
@@ -163,9 +163,9 @@ class ProjectService
|
||||
->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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => []]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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_<action> 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')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php declare(strict_types=1);
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -11,7 +13,7 @@ it('api_key.regenerated logged with key_prefix only (NO plain key)', 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->postJson('/api/api-keys/regenerate');
|
||||
$r->assertStatus(201);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php declare(strict_types=1);
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OutboundWebhookSubscription;
|
||||
use App\Models\Tenant;
|
||||
@@ -12,7 +14,7 @@ it('webhook_settings.updated logged with before/after target_url', 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);
|
||||
|
||||
$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',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php declare(strict_types=1);
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\OperationsLogger;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
@@ -58,5 +61,5 @@ it('append-only: UPDATE blocked by audit_block_mutation', function () {
|
||||
);
|
||||
$id = (int) DB::table('tenant_operations_log')->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);
|
||||
});
|
||||
|
||||
@@ -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_<action>, 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).
|
||||
|
||||
Reference in New Issue
Block a user