2026-05-22 18:44:52 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Models\OutboundWebhookSubscription;
|
|
|
|
|
use App\Models\Project;
|
|
|
|
|
use App\Models\SystemSetting;
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Full operational journaling pipeline integration test.
|
|
|
|
|
*
|
|
|
|
|
* Proves that a realistic mix of 7 operations produces expected rows in all
|
|
|
|
|
* four audit/log tables:
|
|
|
|
|
* - tenant_operations_log (via OperationsLogger)
|
|
|
|
|
* - saas_admin_audit_log (via SaasAdminAuditLog::create)
|
|
|
|
|
* - webhook_log (via SupplierWebhookController::logSupplierWebhook)
|
|
|
|
|
* - incidents_log (via incidents:watch-failures command)
|
|
|
|
|
*/
|
|
|
|
|
it('full operational flow produces rows in all four audit tables', function (): void {
|
|
|
|
|
Queue::fake();
|
|
|
|
|
|
|
|
|
|
// ── Shared fixtures ──────────────────────────────────────────────────────
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
2026-05-22 18:48:03 +03:00
|
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
2026-05-22 18:44:52 +03:00
|
|
|
|
2026-05-22 18:48:03 +03:00
|
|
|
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
2026-05-22 18:44:52 +03:00
|
|
|
|
|
|
|
|
// 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([
|
2026-05-22 18:48:03 +03:00
|
|
|
'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',
|
2026-05-22 18:44:52 +03:00
|
|
|
'is_break_glass' => false,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
$adminId = (int) $adminId;
|
|
|
|
|
|
|
|
|
|
// ── Step 1: Create a project → project.created in tenant_operations_log ──
|
|
|
|
|
$this->actingAs($user)->postJson('/api/projects', [
|
2026-05-22 18:48:03 +03:00
|
|
|
'name' => 'Опфлоу тест',
|
|
|
|
|
'signal_type' => 'site',
|
|
|
|
|
'signal_identifier' => 'opflow-test.ru',
|
2026-05-22 18:44:52 +03:00
|
|
|
'daily_limit_target' => 30,
|
2026-05-22 18:48:03 +03:00
|
|
|
'regions' => [],
|
2026-05-22 18:44:52 +03:00
|
|
|
'delivery_days_mask' => 127,
|
|
|
|
|
])->assertStatus(201);
|
|
|
|
|
|
|
|
|
|
$createdProject = Project::where('tenant_id', $tenant->id)
|
|
|
|
|
->where('signal_identifier', 'opflow-test.ru')
|
|
|
|
|
->firstOrFail();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
DB::table('tenant_operations_log')
|
|
|
|
|
->where('tenant_id', $tenant->id)
|
|
|
|
|
->where('event', 'project.created')
|
|
|
|
|
->where('entity_id', $createdProject->id)
|
|
|
|
|
->exists()
|
|
|
|
|
)->toBeTrue('project.created must be logged');
|
|
|
|
|
|
|
|
|
|
// ── Step 2: Update the project → project.updated in tenant_operations_log ─
|
|
|
|
|
$this->actingAs($user)->patchJson("/api/projects/{$createdProject->id}", [
|
|
|
|
|
'daily_limit_target' => 60,
|
|
|
|
|
])->assertOk();
|
|
|
|
|
|
|
|
|
|
$updatedRow = DB::table('tenant_operations_log')
|
|
|
|
|
->where('tenant_id', $tenant->id)
|
|
|
|
|
->where('event', 'project.updated')
|
|
|
|
|
->where('entity_id', $createdProject->id)
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
expect($updatedRow)->not->toBeNull('project.updated must be logged');
|
|
|
|
|
$after = json_decode($updatedRow->payload_after, true);
|
|
|
|
|
expect((int) $after['daily_limit_target'])->toBe(60);
|
|
|
|
|
|
|
|
|
|
// ── Step 3: Regenerate API key → api_key.regenerated in tenant_operations_log
|
|
|
|
|
$this->actingAs($user)->postJson('/api/api-keys/regenerate')
|
|
|
|
|
->assertStatus(201);
|
|
|
|
|
|
|
|
|
|
$apiKeyRow = DB::table('tenant_operations_log')
|
|
|
|
|
->where('tenant_id', $tenant->id)
|
|
|
|
|
->where('event', 'api_key.regenerated')
|
|
|
|
|
->latest('id')
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
expect($apiKeyRow)->not->toBeNull('api_key.regenerated must be logged');
|
|
|
|
|
$keyPayload = json_decode($apiKeyRow->payload_after, true);
|
|
|
|
|
expect($keyPayload['key_prefix'] ?? null)->not->toBeNull();
|
|
|
|
|
expect(strlen($keyPayload['key_prefix']))->toBeLessThan(20);
|
|
|
|
|
expect(json_encode($keyPayload))->not->toContain('plain_key');
|
|
|
|
|
|
|
|
|
|
// ── Step 4: Change webhook URL → webhook_settings.updated in tenant_operations_log
|
|
|
|
|
$oldUrl = 'https://8.8.8.8/hook';
|
|
|
|
|
OutboundWebhookSubscription::factory()->create([
|
2026-05-22 18:48:03 +03:00
|
|
|
'tenant_id' => $tenant->id,
|
|
|
|
|
'user_id' => $user->id,
|
2026-05-22 18:44:52 +03:00
|
|
|
'target_url' => $oldUrl,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$newUrl = 'https://1.1.1.1/hook';
|
|
|
|
|
$this->actingAs($user)->putJson('/api/tenants/me/webhook-settings', [
|
|
|
|
|
'target_url' => $newUrl,
|
|
|
|
|
])->assertOk();
|
|
|
|
|
|
|
|
|
|
$webhookSettingsRow = DB::table('tenant_operations_log')
|
|
|
|
|
->where('tenant_id', $tenant->id)
|
|
|
|
|
->where('event', 'webhook_settings.updated')
|
|
|
|
|
->latest('id')
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
expect($webhookSettingsRow)->not->toBeNull('webhook_settings.updated must be logged');
|
2026-05-22 18:48:03 +03:00
|
|
|
$wsAfter = json_decode($webhookSettingsRow->payload_after, true);
|
2026-05-22 18:44:52 +03:00
|
|
|
$wsBefore = json_decode($webhookSettingsRow->payload_before, true);
|
|
|
|
|
expect($wsAfter['target_url'] ?? null)->toBe($newUrl);
|
|
|
|
|
expect($wsBefore['target_url'] ?? null)->toBe($oldUrl);
|
|
|
|
|
|
|
|
|
|
// ── Step 5: Admin sets export mode → saas_admin_audit_log ────────────────
|
|
|
|
|
// Reset guards so Sanctum pollution from previous actingAs calls doesn't
|
|
|
|
|
// interfere with the admin endpoint (which uses a different auth context).
|
|
|
|
|
app('auth')->forgetGuards();
|
|
|
|
|
app('auth')->setDefaultDriver('web');
|
|
|
|
|
|
|
|
|
|
// Seed the system_settings key the admin endpoint needs
|
|
|
|
|
DB::table('system_settings')->updateOrInsert(
|
|
|
|
|
['key' => 'supplier_export_mode'],
|
|
|
|
|
['value' => 'batch', 'type' => 'string', 'updated_at' => now()],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$countBefore = DB::table('saas_admin_audit_log')->count();
|
|
|
|
|
|
|
|
|
|
$this->actingAs(User::factory()->create())
|
|
|
|
|
->postJson('/api/admin/supplier-integration/export-mode', [
|
2026-05-22 18:48:03 +03:00
|
|
|
'mode' => 'online',
|
2026-05-22 18:44:52 +03:00
|
|
|
'admin_user_id' => $adminId,
|
|
|
|
|
])->assertOk();
|
|
|
|
|
|
|
|
|
|
expect(DB::table('saas_admin_audit_log')->count())->toBe($countBefore + 1);
|
|
|
|
|
|
|
|
|
|
$adminRow = DB::table('saas_admin_audit_log')->orderByDesc('id')->first();
|
|
|
|
|
expect($adminRow->action)->toBe('supplier_integration.export_mode_set')
|
|
|
|
|
->and($adminRow->admin_user_id)->toBe($adminId);
|
|
|
|
|
|
|
|
|
|
// ── Step 6: Webhook with bad secret → webhook_log (rejected_secret) ──────
|
|
|
|
|
// Ensure the known-good secret is set so a *wrong* secret triggers rejection
|
|
|
|
|
SystemSetting::query()
|
|
|
|
|
->where('key', 'supplier_webhook_secret')
|
|
|
|
|
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
|
|
|
|
SystemSetting::query()
|
|
|
|
|
->where('key', 'supplier_ip_allowlist')
|
|
|
|
|
->update(['value' => '[]']);
|
|
|
|
|
RateLimiter::clear('supplier-webhook:127.0.0.1');
|
|
|
|
|
|
|
|
|
|
$webhookLogCountBefore = DB::table('webhook_log')->count();
|
|
|
|
|
|
|
|
|
|
$this->postJson('/api/webhook/supplier/wrong-secret-here', [
|
2026-05-22 18:48:03 +03:00
|
|
|
'vid' => 999901,
|
2026-05-22 18:44:52 +03:00
|
|
|
'project' => 'B1_opflow-test.ru',
|
2026-05-22 18:48:03 +03:00
|
|
|
'phone' => '79991234567',
|
|
|
|
|
'time' => time(),
|
2026-05-22 18:44:52 +03:00
|
|
|
])->assertStatus(404);
|
|
|
|
|
|
|
|
|
|
expect(DB::table('webhook_log')->count())->toBe($webhookLogCountBefore + 1);
|
|
|
|
|
|
|
|
|
|
$webhookLogRow = DB::table('webhook_log')
|
|
|
|
|
->where('status', 'rejected_secret')
|
|
|
|
|
->where('source', 'supplier')
|
|
|
|
|
->latest('created_at')
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
expect($webhookLogRow)->not->toBeNull('rejected_secret must be in webhook_log');
|
|
|
|
|
expect($webhookLogRow->status)->toBe('rejected_secret');
|
|
|
|
|
|
|
|
|
|
// ── Step 7: Failure storm → incidents_log ─────────────────────────────────
|
|
|
|
|
// Insert 201 failed_webhook_jobs rows (above the 200-threshold)
|
|
|
|
|
$now = now();
|
|
|
|
|
$rows = [];
|
|
|
|
|
for ($i = 0; $i < 201; $i++) {
|
|
|
|
|
$rows[] = [
|
2026-05-22 18:48:03 +03:00
|
|
|
'failed_at' => $now,
|
|
|
|
|
'exception' => 'App\\Exceptions\\WebhookException: opflow-storm',
|
2026-05-22 18:44:52 +03:00
|
|
|
'raw_payload' => '{}',
|
|
|
|
|
'retry_count' => 0,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
DB::table('failed_webhook_jobs')->insert($rows);
|
|
|
|
|
|
|
|
|
|
$this->artisan('incidents:watch-failures')->assertSuccessful();
|
|
|
|
|
|
|
|
|
|
$incident = DB::table('incidents_log')
|
|
|
|
|
->where('summary', 'like', '%201%')
|
|
|
|
|
->latest('id')
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
expect($incident)->not->toBeNull('incidents:watch-failures must create an incident row');
|
|
|
|
|
expect($incident->severity)->toBe('high');
|
|
|
|
|
|
|
|
|
|
// ── Final: confirm all four tables received rows ──────────────────────────
|
|
|
|
|
expect(
|
|
|
|
|
DB::table('tenant_operations_log')
|
|
|
|
|
->where('tenant_id', $tenant->id)
|
|
|
|
|
->count()
|
|
|
|
|
)->toBeGreaterThanOrEqual(4, 'tenant_operations_log must have ≥4 rows for this tenant');
|
|
|
|
|
|
|
|
|
|
expect(DB::table('saas_admin_audit_log')->count())->toBeGreaterThanOrEqual(1);
|
|
|
|
|
expect(DB::table('webhook_log')->where('status', 'rejected_secret')->count())->toBeGreaterThanOrEqual(1);
|
|
|
|
|
expect(DB::table('incidents_log')->count())->toBeGreaterThanOrEqual(1);
|
|
|
|
|
});
|