diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php index 8ee25010..4bf14aed 100644 --- a/app/Modules/Audit/Http/Controllers/DashboardController.php +++ b/app/Modules/Audit/Http/Controllers/DashboardController.php @@ -568,12 +568,13 @@ class DashboardController extends Controller 'reachable' => $scanner->reachable, 'engine' => $scanner->engine, 'definitions_age_hours' => $scanner->definitionsAgeHours(), - // Files that went out unchecked in the last day. Zero is the - // only number that means "protected"; anything else is a - // scanner that was down, or files nobody could open. - 'let_through_24h' => ActivityLog::query() - ->where('action', Action::FileNotScanned) - ->where('created_at', '>=', now()->subDay()) + // Files let through unchecked in the last day that can still + // be downloaded. Zero is the only number that means + // "protected"; anything else is a scanner that was down, or + // files nobody could open. See File::scopeLetThrough(). + 'let_through_24h' => File::query() + ->letThrough() + ->where('scanned_at', '>=', now()->subDay()) ->count(), // Waiting more than an hour: on an installation set to hold, // this is what an outage looks like. diff --git a/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php b/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php index b3dce05c..be572ee6 100644 --- a/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php +++ b/app/Modules/Files/Http/Controllers/VirusScanningSettingsController.php @@ -383,14 +383,7 @@ class VirusScanningSettingsController extends Controller ScanStatus::UnscannableBlocked->value, ])->count(), 'never_scanned' => File::query()->neverScanned()->count(), - 'let_through' => File::query() - ->where('scan_status', ScanStatus::NotScanned) - ->whereIn('scan_note', [ - NotScannedReason::ScannerUnavailable->value, - NotScannedReason::TooLarge->value, - NotScannedReason::Encrypted->value, - ]) - ->count(), + 'let_through' => File::query()->letThrough()->count(), // What a New scan would actually check — see // ScanFileJob::rescannableValues(). 'scannable' => File::query() diff --git a/app/Modules/Files/Models/File.php b/app/Modules/Files/Models/File.php index 29ff31ff..cd7168b9 100644 --- a/app/Modules/Files/Models/File.php +++ b/app/Modules/Files/Models/File.php @@ -319,6 +319,28 @@ class File extends Model }); } + /** + * Files people can download that nothing checked: let through while + * the scanner was down, or because it could not open them. + * + * A state, not a history. The dashboard used to count "let through" + * entries in the activity log, which counted a file once per attempt, + * and went on counting files that had since been deleted, gone + * missing or been scanned clean — none of which is going out + * unscanned. + * + * @param Builder $query + */ + public function scopeLetThrough(Builder $query): void + { + $query->where('scan_status', ScanStatus::NotScanned) + ->whereIn('scan_note', [ + NotScannedReason::ScannerUnavailable->value, + NotScannedReason::TooLarge->value, + NotScannedReason::Encrypted->value, + ]); + } + /** * Files nothing has ever looked at. * diff --git a/app/Modules/Platform/Installation/Console/StatusCommand.php b/app/Modules/Platform/Installation/Console/StatusCommand.php index b3f75331..9d297b91 100644 --- a/app/Modules/Platform/Installation/Console/StatusCommand.php +++ b/app/Modules/Platform/Installation/Console/StatusCommand.php @@ -445,12 +445,13 @@ class StatusCommand extends Command ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value, ])->count(), - // Files that went out unchecked in the last day. Zero is the - // only number that means "protected"; anything else is a - // scanner that was down, or files nobody could open. - 'let_through_24h' => ActivityLog::query() - ->where('action', Action::FileNotScanned) - ->where('created_at', '>=', now()->subDay()) + // Files let through unchecked in the last day that can still + // be downloaded. Zero is the only number that means + // "protected"; anything else is a scanner that was down, or + // files nobody could open. See File::scopeLetThrough(). + 'let_through_24h' => File::query() + ->letThrough() + ->where('scanned_at', '>=', now()->subDay()) ->count(), ]; } diff --git a/tests/Feature/Files/VirusScanningSettingsTest.php b/tests/Feature/Files/VirusScanningSettingsTest.php index c3516582..8e84d1c5 100644 --- a/tests/Feature/Files/VirusScanningSettingsTest.php +++ b/tests/Feature/Files/VirusScanningSettingsTest.php @@ -292,8 +292,10 @@ test('the status command reports an unreachable scanner and what got through', f app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://nowhere.test:3310'); app()->instance(VirusScanner::class, (new FakeVirusScanner)->reports(ScannerStatus::unreachable('no answer'))); - app(App\Modules\Audit\ActivityLogger::class)->logSystem(App\Modules\Audit\Action::FileNotScanned, [ - 'id' => 1, 'name' => 'x', 'reason' => 'scanner_unavailable', + File::factory()->create([ + 'scan_status' => ScanStatus::NotScanned, + 'scan_note' => NotScannedReason::ScannerUnavailable->value, + 'scanned_at' => now(), ]); // statusJson() lives in StatusCommandTest — Pest loads every test @@ -626,3 +628,37 @@ test('a retry scheduled for later is not a scan in progress', function () { ->assertJsonPath('running', true); }); +test('"let through" counts files that can still be downloaded unchecked, once each', function () { + // What the dashboard reported after a day of real use: 62, for 16 + // files, none of them downloadable. It counted activity log entries — + // one per attempt, and still counting files since deleted, gone + // missing or scanned clean. + app(Settings::class)->set(Setting::VirusScanningEnabled, true); + app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310'); + app()->instance(VirusScanner::class, new FakeVirusScanner); + + $letThrough = fn (array $overrides = []): File => File::factory()->create(array_merge([ + 'scan_status' => ScanStatus::NotScanned, + 'scan_note' => NotScannedReason::ScannerUnavailable->value, + 'scanned_at' => now(), + ], $overrides)); + + $out = $letThrough(); + $letThrough()->delete(); + $letThrough(['scan_status' => ScanStatus::Missing, 'scan_note' => null]); + $letThrough(['scan_status' => ScanStatus::Clean, 'scan_note' => null]); + $letThrough(['scanned_at' => now()->subDays(2)]); + + foreach (range(1, 3) as $attempt) { + app(App\Modules\Audit\ActivityLogger::class)->logSystem(App\Modules\Audit\Action::FileNotScanned, [ + 'id' => $out->id, 'name' => $out->name, 'reason' => 'scanner_unavailable', + ]); + } + + $this->actingAs($this->admin)->get('/dashboard')->assertInertia( + fn (AssertableInertia $page) => $page->where('system.scanning.let_through_24h', 1), + ); + + expect(scanningStatus()['let_through_24h'])->toBe(1); +}); +