Add checksum validation for archived media
This commit is contained in:
@@ -1 +1 @@
|
|||||||
fotospiel-app-v00l
|
fotospiel-app-e05
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ class MonitorStorageCommand extends Command
|
|||||||
|
|
||||||
$assetStats = $this->buildAssetStatistics();
|
$assetStats = $this->buildAssetStatistics();
|
||||||
$thresholds = $this->capacityThresholds();
|
$thresholds = $this->capacityThresholds();
|
||||||
|
$checksumConfig = $this->checksumAlertConfig();
|
||||||
|
$checksumWindowMinutes = $checksumConfig['window_minutes'];
|
||||||
|
$checksumThresholds = $checksumConfig['thresholds'];
|
||||||
|
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
|
||||||
|
? $this->checksumMismatchCounts($checksumWindowMinutes)
|
||||||
|
: [];
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
$snapshotTargets = [];
|
$snapshotTargets = [];
|
||||||
|
|
||||||
@@ -78,6 +84,7 @@ class MonitorStorageCommand extends Command
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
|
||||||
$snapshotTargets[] = [
|
$snapshotTargets[] = [
|
||||||
'id' => $target->id,
|
'id' => $target->id,
|
||||||
'key' => $target->key,
|
'key' => $target->key,
|
||||||
@@ -85,13 +92,35 @@ class MonitorStorageCommand extends Command
|
|||||||
'is_hot' => (bool) $target->is_hot,
|
'is_hot' => (bool) $target->is_hot,
|
||||||
'capacity' => $capacity,
|
'capacity' => $capacity,
|
||||||
'assets' => $assets,
|
'assets' => $assets,
|
||||||
|
'checksum_mismatches' => [
|
||||||
|
'count' => $targetChecksumMismatches,
|
||||||
|
'window_minutes' => $checksumWindowMinutes,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
|
||||||
|
$totalMismatches = array_sum($checksumMismatches);
|
||||||
|
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
|
||||||
|
|
||||||
|
if ($checksumSeverity !== 'ok') {
|
||||||
|
$alerts[] = [
|
||||||
|
'type' => 'checksum_mismatch',
|
||||||
|
'severity' => $checksumSeverity,
|
||||||
|
'count' => $totalMismatches,
|
||||||
|
'window_minutes' => $checksumWindowMinutes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$snapshot = [
|
$snapshot = [
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => now()->toIso8601String(),
|
||||||
'targets' => $snapshotTargets,
|
'targets' => $snapshotTargets,
|
||||||
'alerts' => $alerts,
|
'alerts' => $alerts,
|
||||||
|
'checksum' => [
|
||||||
|
'window_minutes' => $checksumWindowMinutes,
|
||||||
|
'mismatch_total' => array_sum($checksumMismatches),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
||||||
@@ -191,4 +220,62 @@ class MonitorStorageCommand extends Command
|
|||||||
|
|
||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checksumAlertConfig(): array
|
||||||
|
{
|
||||||
|
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
|
||||||
|
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
|
||||||
|
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
|
||||||
|
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
|
||||||
|
|
||||||
|
if ($warning > $critical && $critical > 0) {
|
||||||
|
[$warning, $critical] = [$critical, $warning];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => $enabled,
|
||||||
|
'window_minutes' => $windowMinutes,
|
||||||
|
'thresholds' => [
|
||||||
|
'warning' => $warning,
|
||||||
|
'critical' => $critical,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checksumMismatchCounts(int $windowMinutes): array
|
||||||
|
{
|
||||||
|
$query = EventMediaAsset::query()
|
||||||
|
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
|
||||||
|
->where('status', 'failed')
|
||||||
|
->where('meta->checksum_status', 'mismatch');
|
||||||
|
|
||||||
|
if ($windowMinutes > 0) {
|
||||||
|
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->groupBy('media_storage_target_id')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determineChecksumSeverity(int $count, array $thresholds): string
|
||||||
|
{
|
||||||
|
$warning = (int) ($thresholds['warning'] ?? 1);
|
||||||
|
$critical = (int) ($thresholds['critical'] ?? 5);
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($critical > 0 && $count >= $critical) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($warning > 0 && $count >= $warning) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,12 +71,44 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
|||||||
|
|
||||||
Storage::disk($archiveDisk)->put($archivePath, $stream);
|
Storage::disk($archiveDisk)->put($archivePath, $stream);
|
||||||
|
|
||||||
|
$checksumMeta = null;
|
||||||
|
$archiveChecksum = null;
|
||||||
|
if ($this->checksumValidationEnabled()) {
|
||||||
|
$archiveChecksum = $this->computeChecksum($archiveDisk, $archivePath);
|
||||||
|
if (! $archiveChecksum) {
|
||||||
|
throw new \RuntimeException('Archive checksum unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedChecksum = $asset->checksum;
|
||||||
|
if ($expectedChecksum) {
|
||||||
|
if (! hash_equals($expectedChecksum, $archiveChecksum)) {
|
||||||
|
$this->handleChecksumMismatch($asset, $expectedChecksum, $archiveChecksum, $sourceDisk, $archiveDisk);
|
||||||
|
$this->deleteArchiveCopy($archiveDisk, $archivePath);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checksumMeta = [
|
||||||
|
'checksum_status' => 'verified',
|
||||||
|
'checksum_verified_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$asset->checksum = $archiveChecksum;
|
||||||
|
$checksumMeta = [
|
||||||
|
'checksum_status' => 'seeded',
|
||||||
|
'checksum_verified_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$asset->fill([
|
$asset->fill([
|
||||||
'disk' => $archiveDisk,
|
'disk' => $archiveDisk,
|
||||||
'media_storage_target_id' => $archiveTargetId,
|
'media_storage_target_id' => $archiveTargetId,
|
||||||
'status' => 'archived',
|
'status' => 'archived',
|
||||||
'archived_at' => now(),
|
'archived_at' => now(),
|
||||||
'error_message' => null,
|
'error_message' => null,
|
||||||
|
'checksum' => $asset->checksum,
|
||||||
|
'meta' => $this->mergeMeta($asset->meta, $checksumMeta),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
if ($this->deleteSource) {
|
if ($this->deleteSource) {
|
||||||
@@ -102,4 +134,92 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checksumValidationEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('storage-monitor.checksum_validation.enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeChecksum(string $disk, string $path): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stream = Storage::disk($disk)->readStream($path);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::channel('storage-jobs')->warning('Failed to open stream for checksum', [
|
||||||
|
'disk' => $disk,
|
||||||
|
'path' => $path,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $stream) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$context = hash_init('sha256');
|
||||||
|
$ok = hash_update_stream($context, $stream);
|
||||||
|
if ($ok === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_final($context);
|
||||||
|
} finally {
|
||||||
|
if (is_resource($stream)) {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleChecksumMismatch(
|
||||||
|
EventMediaAsset $asset,
|
||||||
|
string $expectedChecksum,
|
||||||
|
string $actualChecksum,
|
||||||
|
string $sourceDisk,
|
||||||
|
string $archiveDisk,
|
||||||
|
): void {
|
||||||
|
Log::channel('storage-jobs')->alert('Checksum mismatch detected during archive', [
|
||||||
|
'asset_id' => $asset->id,
|
||||||
|
'event_id' => $asset->event_id,
|
||||||
|
'source_disk' => $sourceDisk,
|
||||||
|
'archive_disk' => $archiveDisk,
|
||||||
|
'expected_checksum' => $expectedChecksum,
|
||||||
|
'actual_checksum' => $actualChecksum,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$asset->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'error_message' => 'checksum_mismatch',
|
||||||
|
'meta' => $this->mergeMeta($asset->meta, [
|
||||||
|
'checksum_status' => 'mismatch',
|
||||||
|
'checksum_verified_at' => now()->toIso8601String(),
|
||||||
|
'checksum_expected' => $expectedChecksum,
|
||||||
|
'checksum_actual' => $actualChecksum,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteArchiveCopy(string $archiveDisk, string $path): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Storage::disk($archiveDisk)->delete($path);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::channel('storage-jobs')->warning('Failed to clean up archive copy after checksum mismatch', [
|
||||||
|
'disk' => $archiveDisk,
|
||||||
|
'path' => $path,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mergeMeta(?array $meta, ?array $updates): ?array
|
||||||
|
{
|
||||||
|
if (! $updates) {
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($meta ?? [], $updates);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ return [
|
|||||||
'critical' => (int) env('STORAGE_CAPACITY_CRITICAL', 90),
|
'critical' => (int) env('STORAGE_CAPACITY_CRITICAL', 90),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'checksum_validation' => [
|
||||||
|
'enabled' => (bool) env('STORAGE_CHECKSUM_VALIDATION', true),
|
||||||
|
'alert_window_minutes' => (int) env('STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES', 60),
|
||||||
|
'thresholds' => [
|
||||||
|
'warning' => (int) env('STORAGE_CHECKSUM_WARNING', 1),
|
||||||
|
'critical' => (int) env('STORAGE_CHECKSUM_CRITICAL', 5),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'monitor' => [
|
'monitor' => [
|
||||||
'lock_seconds' => (int) env('STORAGE_MONITOR_LOCK_SECONDS', 300),
|
'lock_seconds' => (int) env('STORAGE_MONITOR_LOCK_SECONDS', 300),
|
||||||
'cache_minutes' => (int) env('STORAGE_MONITOR_CACHE_MINUTES', 15),
|
'cache_minutes' => (int) env('STORAGE_MONITOR_CACHE_MINUTES', 15),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ This document explains how customer photo uploads move through the Fotospiel pla
|
|||||||
| Security queue | `.env SECURITY_SCAN_QUEUE` & `config/security.php` | Defaults to `media-security`. |
|
| Security queue | `.env SECURITY_SCAN_QUEUE` & `config/security.php` | Defaults to `media-security`. |
|
||||||
| Archive scheduling | `config/storage-monitor.php['archive']` | Controls grace days, chunk size, locking, and dispatch caps. |
|
| Archive scheduling | `config/storage-monitor.php['archive']` | Controls grace days, chunk size, locking, and dispatch caps. |
|
||||||
| Queue health alerts | `config/storage-monitor.php['queue_health']` | Warning/critical thresholds for `media-storage` and `media-security` queues. |
|
| Queue health alerts | `config/storage-monitor.php['queue_health']` | Warning/critical thresholds for `media-storage` and `media-security` queues. |
|
||||||
|
| Checksum alerts | `config/storage-monitor.php['checksum_validation']` | Enables checksum verification alerts and thresholding window. |
|
||||||
| Container volumes | `docker-compose.yml` | `app`, workers, and scheduler share the `app-code` volume so `/var/www/html/storage` is common. |
|
| Container volumes | `docker-compose.yml` | `app`, workers, and scheduler share the `app-code` volume so `/var/www/html/storage` is common. |
|
||||||
|
|
||||||
## Operational Checklist
|
## Operational Checklist
|
||||||
@@ -72,6 +73,7 @@ This document explains how customer photo uploads move through the Fotospiel pla
|
|||||||
- Watch `storage:monitor` output (email or logs) for capacity warnings on hot disks.
|
- Watch `storage:monitor` output (email or logs) for capacity warnings on hot disks.
|
||||||
- Use Horizon or Redis metrics to verify `media-storage` queue depth; thresholds live in `config/storage-monitor.php`.
|
- Use Horizon or Redis metrics to verify `media-storage` queue depth; thresholds live in `config/storage-monitor.php`.
|
||||||
- Review `/var/www/html/storage/logs/storage-jobs.log` (if configured) for archival failures.
|
- Review `/var/www/html/storage/logs/storage-jobs.log` (if configured) for archival failures.
|
||||||
|
- Checksum mismatches (hot→archive) are flagged by `storage:monitor` using `checksum_validation` thresholds.
|
||||||
- Ensure `media-security` queue stays below critical thresholds so uploads aren’t blocked awaiting security scans.
|
- Ensure `media-security` queue stays below critical thresholds so uploads aren’t blocked awaiting security scans.
|
||||||
|
|
||||||
- **Troubleshooting uploads**
|
- **Troubleshooting uploads**
|
||||||
|
|||||||
129
tests/Feature/ArchiveEventMediaAssetsTest.php
Normal file
129
tests/Feature/ArchiveEventMediaAssetsTest.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Jobs\ArchiveEventMediaAssets;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventMediaAsset;
|
||||||
|
use App\Models\MediaStorageTarget;
|
||||||
|
use App\Services\Storage\EventStorageManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ArchiveEventMediaAssetsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_archives_asset_and_verifies_checksum(): void
|
||||||
|
{
|
||||||
|
Storage::fake('hot-disk');
|
||||||
|
Storage::fake('archive-disk');
|
||||||
|
|
||||||
|
$hotTarget = MediaStorageTarget::create([
|
||||||
|
'key' => 'hot-disk',
|
||||||
|
'name' => 'Hot Disk',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => [],
|
||||||
|
'is_hot' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MediaStorageTarget::create([
|
||||||
|
'key' => 'archive-disk',
|
||||||
|
'name' => 'Archive Disk',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => [],
|
||||||
|
'is_hot' => false,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create();
|
||||||
|
$path = 'events/'.$event->id.'/photo.jpg';
|
||||||
|
Storage::disk('hot-disk')->put($path, 'photo-body');
|
||||||
|
|
||||||
|
$checksum = hash('sha256', 'photo-body');
|
||||||
|
|
||||||
|
$asset = EventMediaAsset::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'media_storage_target_id' => $hotTarget->id,
|
||||||
|
'photo_id' => null,
|
||||||
|
'variant' => 'original',
|
||||||
|
'disk' => 'hot-disk',
|
||||||
|
'path' => $path,
|
||||||
|
'size_bytes' => 10,
|
||||||
|
'checksum' => $checksum,
|
||||||
|
'status' => 'hot',
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new ArchiveEventMediaAssets($event->id, true))->handle(app(EventStorageManager::class));
|
||||||
|
|
||||||
|
$asset->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('archived', $asset->status);
|
||||||
|
$this->assertSame('archive-disk', $asset->disk);
|
||||||
|
$this->assertSame('verified', data_get($asset->meta, 'checksum_status'));
|
||||||
|
$this->assertNotEmpty(data_get($asset->meta, 'checksum_verified_at'));
|
||||||
|
Storage::disk('archive-disk')->assertExists($path);
|
||||||
|
Storage::disk('hot-disk')->assertMissing($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_marks_asset_failed_on_checksum_mismatch(): void
|
||||||
|
{
|
||||||
|
Storage::fake('hot-disk');
|
||||||
|
Storage::fake('archive-disk');
|
||||||
|
|
||||||
|
$hotTarget = MediaStorageTarget::create([
|
||||||
|
'key' => 'hot-disk',
|
||||||
|
'name' => 'Hot Disk',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => [],
|
||||||
|
'is_hot' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MediaStorageTarget::create([
|
||||||
|
'key' => 'archive-disk',
|
||||||
|
'name' => 'Archive Disk',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => [],
|
||||||
|
'is_hot' => false,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create();
|
||||||
|
$path = 'events/'.$event->id.'/photo.jpg';
|
||||||
|
Storage::disk('hot-disk')->put($path, 'photo-body');
|
||||||
|
|
||||||
|
$asset = EventMediaAsset::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'media_storage_target_id' => $hotTarget->id,
|
||||||
|
'photo_id' => null,
|
||||||
|
'variant' => 'original',
|
||||||
|
'disk' => 'hot-disk',
|
||||||
|
'path' => $path,
|
||||||
|
'size_bytes' => 10,
|
||||||
|
'checksum' => hash('sha256', 'different-body'),
|
||||||
|
'status' => 'hot',
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new ArchiveEventMediaAssets($event->id, true))->handle(app(EventStorageManager::class));
|
||||||
|
|
||||||
|
$asset->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('failed', $asset->status);
|
||||||
|
$this->assertSame('checksum_mismatch', $asset->error_message);
|
||||||
|
$this->assertSame('mismatch', data_get($asset->meta, 'checksum_status'));
|
||||||
|
$this->assertSame('hot-disk', $asset->disk);
|
||||||
|
Storage::disk('hot-disk')->assertExists($path);
|
||||||
|
Storage::disk('archive-disk')->assertMissing($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,4 +75,62 @@ class MonitorStorageCommandTest extends TestCase
|
|||||||
$this->assertSame(2, $snapshot['targets'][0]['assets']['total']);
|
$this->assertSame(2, $snapshot['targets'][0]['assets']['total']);
|
||||||
$this->assertGreaterThanOrEqual(1, count($snapshot['alerts']));
|
$this->assertGreaterThanOrEqual(1, count($snapshot['alerts']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_monitor_command_flags_checksum_mismatches(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'storage-monitor.checksum_validation.enabled' => true,
|
||||||
|
'storage-monitor.checksum_validation.alert_window_minutes' => 60,
|
||||||
|
'storage-monitor.checksum_validation.thresholds.warning' => 1,
|
||||||
|
'storage-monitor.checksum_validation.thresholds.critical' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$target = MediaStorageTarget::create([
|
||||||
|
'key' => 'local-ssd',
|
||||||
|
'name' => 'Local SSD',
|
||||||
|
'driver' => 'local',
|
||||||
|
'config' => ['monitor_path' => storage_path('app')],
|
||||||
|
'is_hot' => true,
|
||||||
|
'is_default' => true,
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create();
|
||||||
|
|
||||||
|
EventMediaAsset::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'media_storage_target_id' => $target->id,
|
||||||
|
'photo_id' => null,
|
||||||
|
'variant' => 'original',
|
||||||
|
'disk' => 'local-ssd',
|
||||||
|
'path' => 'events/'.$event->id.'/photo.jpg',
|
||||||
|
'size_bytes' => 2048,
|
||||||
|
'status' => 'failed',
|
||||||
|
'meta' => [
|
||||||
|
'checksum_status' => 'mismatch',
|
||||||
|
'checksum_verified_at' => now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$health = Mockery::mock(StorageHealthService::class);
|
||||||
|
$health->shouldReceive('getCapacity')->andReturn([
|
||||||
|
'status' => 'ok',
|
||||||
|
'total' => 100,
|
||||||
|
'free' => 90,
|
||||||
|
'used' => 10,
|
||||||
|
'percentage' => 10,
|
||||||
|
'path' => storage_path('app'),
|
||||||
|
]);
|
||||||
|
$this->app->instance(StorageHealthService::class, $health);
|
||||||
|
|
||||||
|
$this->artisan('storage:monitor')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
$snapshot = Cache::get('storage:monitor:last');
|
||||||
|
|
||||||
|
$this->assertNotNull($snapshot);
|
||||||
|
$alerts = collect($snapshot['alerts'] ?? []);
|
||||||
|
$this->assertTrue($alerts->contains(fn ($alert) => ($alert['type'] ?? null) === 'checksum_mismatch'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user