Files
portal/app/tests/Feature/Audit/OperationalFullFlowTest.php
T

221 lines
8.9 KiB
PHP

<?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();
$user = User::factory()->create(['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',
'is_break_glass' => false,
]);
}
$adminId = (int) $adminId;
// ── 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',
'daily_limit_target' => 30,
'regions' => [],
'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([
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'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');
$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);
// ── 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', [
'mode' => 'online',
'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', [
'vid' => 999901,
'project' => 'B1_opflow-test.ru',
'phone' => '79991234567',
'time' => time(),
])->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[] = [
'failed_at' => $now,
'exception' => 'App\\Exceptions\\WebhookException: opflow-storm',
'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);
});