monitoring: Betriebsüberwachung Phase 1 — GlitchTip (Server- und Browserfehler), Fehlersignale ohne Exception, /health mit Scheduler-Herzschlag, ntfy-Alarme, fehlgeschlagene Anmeldungen im Verlauf, Umami im Kundenbereich, Versionsnummer
- GlitchTip über die Sentry-SDKs; Datenschutzfilter (nur User-ID, keine
Request-Bodys, Tokens aus URL/Query/Referer, E-Mails aus Meldungen).
Browser-SDK erst beim ersten Fehler nachgeladen.
- ReportErrorSignals: tote interne Links (404 mit eigenem Referrer), 403/429
für Angemeldete, 419 als gedrosselte Warnung.
- /health (Bearer-Token): DB, Cache, Queue-Rückstau, Scheduler-Herzschlag,
Plattenplatz → 200/503; Scheduler und Plattenplatz auch im Systemstatus.
- OperationalAlert zusätzlich per ntfy (JSON-POST), unabhängig von der Mail.
- Verlauf: login_failed (Grund unbekannt/kein Passwort/falsch),
login_lockout, two_factor_failed; IP nur als gekürzter HMAC.
- Umami: Kundenbereich mit eigenem Schalter und identify('user-<id>'),
nie im Admin/bei Impersonation; Query-Filter auch auf den Webseiten
(signierte Links gingen bisher an Umami).
- queue:prune-failed nach 30 Tagen.
- Versionsnummer: config('app.version') aus APP_VERSION oder Datei RELEASE
(git describe --tags), in GlitchTip, /health, Systemstatus, artisan about.
Plan: dev/betrieb/monitoring-plan.md (Entscheidungen M1–M9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2079dfdaa8
commit
d884eb7482
42 changed files with 1896 additions and 9 deletions
|
|
@ -40,6 +40,18 @@ test('admin can open the system health report', function () {
|
|||
->assertSee('Queue-Worker');
|
||||
});
|
||||
|
||||
test('the system health report shows the deployed version', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('app.version', 'v1.1.0');
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get('https://pressekonto.test/admin/reports/system-health')
|
||||
->assertSuccessful()
|
||||
->assertSee('Version: v1.1.0');
|
||||
});
|
||||
|
||||
test('snapshot reports an ok queue when there are no waiting jobs', function () {
|
||||
/** @var TestCase $this */
|
||||
$snapshot = app(AdminSystemHealth::class)->snapshot();
|
||||
|
|
|
|||
76
tests/Feature/Auth/FailedLoginActivityTest.php
Normal file
76
tests/Feature/Auth/FailedLoginActivityTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
/**
|
||||
* Gescheiterte Anmeldungen im Aktivitäts-Verlauf (Monitoring-Plan 1.7, M9):
|
||||
* mit Grund und User-Bezug, ohne Passwort und ohne Klartext-IP.
|
||||
*/
|
||||
function failedLoginEvents(ActivityType $type)
|
||||
{
|
||||
return ActivityEvent::query()->where('type', $type->value)->orderBy('id')->get();
|
||||
}
|
||||
|
||||
test('a wrong password is recorded with the account and the reason', function () {
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
|
||||
Volt::test('auth.login')
|
||||
->set('email', 'kunde@example.com')
|
||||
->set('password', 'falsches-passwort')
|
||||
->call('login')
|
||||
->assertHasErrors('email');
|
||||
|
||||
$event = failedLoginEvents(ActivityType::LoginFailed)->sole();
|
||||
|
||||
expect($event->user_id)->toBe($user->id)
|
||||
->and($event->meta['reason'])->toBe('wrong_password')
|
||||
->and($event->meta['ip_hash'])->toHaveLength(12)
|
||||
->and($event->description)->toContain('falsches Passwort')
|
||||
->and(json_encode($event->toArray()))->not->toContain('falsches-passwort')
|
||||
->and(json_encode($event->toArray()))->not->toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
test('an account without password is told apart from an unknown address', function () {
|
||||
User::factory()->create(['email' => 'legacy@example.com', 'password' => null]);
|
||||
|
||||
foreach (['legacy@example.com', 'niemand@example.com'] as $email) {
|
||||
Volt::test('auth.login')->set('email', $email)->set('password', 'irgendwas')->call('login');
|
||||
}
|
||||
|
||||
$events = failedLoginEvents(ActivityType::LoginFailed);
|
||||
|
||||
expect($events->pluck('meta.reason')->all())->toBe(['no_password', 'unknown_account'])
|
||||
->and($events[1]->user_id)->toBeNull()
|
||||
->and($events[1]->description)->not->toContain('niemand@example.com');
|
||||
});
|
||||
|
||||
test('a lockout is recorded for the targeted account', function () {
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
|
||||
foreach (range(1, 6) as $attempt) {
|
||||
Volt::test('auth.login')->set('email', 'kunde@example.com')->set('password', 'falsch')->call('login');
|
||||
}
|
||||
|
||||
$lockout = failedLoginEvents(ActivityType::LoginLockout)->sole();
|
||||
|
||||
expect($lockout->user_id)->toBe($user->id)
|
||||
->and($lockout->description)->toContain('gesperrt')
|
||||
->and(failedLoginEvents(ActivityType::LoginFailed))->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('a wrong two-factor code is recorded', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->withSession(['login.id' => $user->id, 'login.remember' => false])
|
||||
->post('/two-factor-challenge', ['code' => '000000']);
|
||||
|
||||
expect(failedLoginEvents(ActivityType::TwoFactorFailed)->sole()->user_id)->toBe($user->id);
|
||||
});
|
||||
38
tests/Feature/Monitoring/BrowserErrorReportingTest.php
Normal file
38
tests/Feature/Monitoring/BrowserErrorReportingTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
/**
|
||||
* Browserfehler (Monitoring-Plan 1.2): Meta-Konfiguration und Skript nur mit
|
||||
* DSN; die User-ID ist der einzige Personenbezug.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
});
|
||||
|
||||
test('nothing is rendered without a browser dsn', function () {
|
||||
config()->set('monitoring.browser.dsn', null);
|
||||
|
||||
expect(Blade::render('<x-monitoring.browser-errors />'))->toBe('');
|
||||
});
|
||||
|
||||
test('the configuration carries dsn, release and the user id only', function () {
|
||||
config()->set('monitoring.browser.dsn', 'https://public@glitchtip.test/2');
|
||||
config()->set('sentry.release', 'abc1234');
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = Blade::render('<x-monitoring.browser-errors build="build/portal" area="portal" />');
|
||||
|
||||
preg_match('/<meta name="pk-error-reporting" content="([^"]+)"/', $html, $match);
|
||||
$config = json_decode(html_entity_decode($match[1]), true);
|
||||
|
||||
expect($config)->toMatchArray([
|
||||
'dsn' => 'https://public@glitchtip.test/2',
|
||||
'release' => 'abc1234',
|
||||
'userId' => $user->id,
|
||||
'area' => 'portal',
|
||||
])
|
||||
->and($html)->not->toContain('kunde@example.com');
|
||||
});
|
||||
77
tests/Feature/Monitoring/ErrorReportingTest.php
Normal file
77
tests/Feature/Monitoring/ErrorReportingTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Sentry\Event;
|
||||
use Sentry\ExceptionDataBag;
|
||||
use Tests\Support\FakeGlitchTip;
|
||||
|
||||
/**
|
||||
* GlitchTip-Anbindung (Monitoring-Plan 1.1, M4): gemeldete Exceptions gehen
|
||||
* raus, aber nur mit User-ID, ohne Tokens und ohne E-Mail-Adressen.
|
||||
*/
|
||||
test('reported exceptions reach glitchtip with the user id only', function () {
|
||||
$glitchTip = FakeGlitchTip::install();
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
report(new RuntimeException('Import für kunde@example.com fehlgeschlagen'));
|
||||
|
||||
expect($glitchTip->events)->toHaveCount(1);
|
||||
|
||||
$event = $glitchTip->events[0];
|
||||
expect($event->getUser()?->getId())->toBe($user->id)
|
||||
->and($event->getUser()?->getEmail())->toBeNull()
|
||||
->and($event->getExceptions()[0]->getValue())->toBe('Import für [email] fehlgeschlagen');
|
||||
});
|
||||
|
||||
test('exceptions laravel does not report stay out of glitchtip', function () {
|
||||
$glitchTip = FakeGlitchTip::install();
|
||||
|
||||
$this->get('/diese-seite-gibt-es-nicht')->assertNotFound();
|
||||
|
||||
expect($glitchTip->events)->toBe([]);
|
||||
});
|
||||
|
||||
test('request data loses tokens, signatures and bodies', function () {
|
||||
$scrubbed = ErrorReportScrubber::scrubRequest([
|
||||
'url' => 'https://pressekonto.test/newsroom/claim/5?signature=abc123&expires=1700000000&src=mail_publish',
|
||||
'query_string' => 'signature=abc123&expires=1700000000&src=mail_publish&utm_source=nl',
|
||||
'data' => ['password' => 'geheim'],
|
||||
'cookies' => ['pressekonto_session' => 'x'],
|
||||
'headers' => ['referer' => ['https://pressekonto.test/reset-password/'.str_repeat('a', 64).'?email=kunde@example.com']],
|
||||
]);
|
||||
|
||||
expect($scrubbed['url'])->toBe('https://pressekonto.test/newsroom/claim/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&src=mail_publish')
|
||||
->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&src=mail_publish&utm_source=nl')
|
||||
->and($scrubbed)->not->toHaveKeys(['data', 'cookies'])
|
||||
->and($scrubbed['headers']['referer'][0])->toBe('https://pressekonto.test/reset-password/[Filtered]?email=%5BFiltered%5D');
|
||||
});
|
||||
|
||||
test('route parameters named like secrets are removed from the url', function () {
|
||||
Route::middleware('web')->get('/_test/magic/{token}', function () {
|
||||
$event = Event::createEvent();
|
||||
$event->setRequest(['url' => request()->fullUrl()]);
|
||||
|
||||
return ErrorReportScrubber::beforeSend($event)?->getRequest()['url'];
|
||||
});
|
||||
|
||||
$this->get('/_test/magic/kurz123')
|
||||
->assertSuccessful()
|
||||
->assertSee('/_test/magic/[Filtered]', false)
|
||||
->assertDontSee('kurz123');
|
||||
});
|
||||
|
||||
test('messages and exception values lose e-mail addresses', function () {
|
||||
$event = Event::createEvent();
|
||||
$event->setMessage('Bounce von info@firma.de', [], 'Bounce von info@firma.de');
|
||||
$event->setExceptions([new ExceptionDataBag(new RuntimeException('Duplicate entry \'a.b@c.org\''))]);
|
||||
|
||||
$scrubbed = ErrorReportScrubber::beforeSend($event);
|
||||
|
||||
expect($scrubbed?->getMessage())->toBe('Bounce von [email]')
|
||||
->and($scrubbed?->getMessageFormatted())->toBe('Bounce von [email]')
|
||||
->and($scrubbed?->getExceptions()[0]->getValue())->toBe('Duplicate entry \'[email]\'')
|
||||
->and($scrubbed?->getUser())->toBeNull();
|
||||
});
|
||||
71
tests/Feature/Monitoring/ErrorSignalsTest.php
Normal file
71
tests/Feature/Monitoring/ErrorSignalsTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Monitoring\ErrorSignal;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\Support\FakeGlitchTip;
|
||||
|
||||
/**
|
||||
* Fehler ohne Exception (Monitoring-Plan 1.3): tote interne Links,
|
||||
* 403/429 für Angemeldete und 419 werden als GlitchTip-Warnung gemeldet.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
$this->glitchTip = FakeGlitchTip::install();
|
||||
|
||||
Route::middleware('web')->get('/_test/forbidden', fn () => abort(403))->name('test.forbidden');
|
||||
Route::middleware('web')->get('/_test/expired', fn () => abort(419))->name('test.expired');
|
||||
Route::middleware('web')->get('/_test/throttled', fn () => abort(429))->name('test.throttled');
|
||||
});
|
||||
|
||||
test('a 404 reached from one of our own pages is reported as a dead internal link', function () {
|
||||
$this->get('/gibt-es-nicht', ['referer' => url('/de/presse')])->assertNotFound();
|
||||
|
||||
expect($this->glitchTip->messages())->toBe(['Toter interner Link: /gibt-es-nicht']);
|
||||
|
||||
$event = $this->glitchTip->events[0];
|
||||
expect($event->getTags())->toMatchArray(['signal' => ErrorSignal::DeadInternalLink])
|
||||
->and($event->getFingerprint())->toBe(['error-signal', ErrorSignal::DeadInternalLink, '/gibt-es-nicht'])
|
||||
->and($event->getContexts()['signal']['referer'])->toBe('/de/presse');
|
||||
});
|
||||
|
||||
test('a 404 without referer or from another site is not reported', function () {
|
||||
$this->get('/gibt-es-nicht')->assertNotFound();
|
||||
$this->get('/gibt-es-nicht', ['referer' => 'https://google.com/search'])->assertNotFound();
|
||||
|
||||
expect($this->glitchTip->events)->toBe([]);
|
||||
});
|
||||
|
||||
test('403 and 429 are reported only for signed-in users', function () {
|
||||
$this->get('/_test/forbidden')->assertForbidden();
|
||||
$this->get('/_test/throttled')->assertTooManyRequests();
|
||||
|
||||
expect($this->glitchTip->events)->toBe([]);
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->get('/_test/forbidden')->assertForbidden();
|
||||
$this->get('/_test/throttled')->assertTooManyRequests();
|
||||
|
||||
expect($this->glitchTip->messages())->toBe([
|
||||
'Zugriff verweigert (angemeldet): test.forbidden',
|
||||
'Drosselung trifft angemeldeten Nutzer: test.throttled',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an expired page is always reported, for livewire with the page as location', function () {
|
||||
$this->get('/_test/expired')->assertStatus(419);
|
||||
$this->get('/_test/expired', ['X-Livewire' => '1', 'referer' => url('/me/pressemitteilungen/neu')])->assertStatus(419);
|
||||
|
||||
expect($this->glitchTip->messages())->toBe([
|
||||
'Seite abgelaufen (419): test.expired',
|
||||
'Seite abgelaufen (419): livewire /me/pressemitteilungen/neu',
|
||||
]);
|
||||
});
|
||||
|
||||
test('each signal group is capped at five reports per window', function () {
|
||||
foreach (range(1, 7) as $attempt) {
|
||||
$this->get('/_test/expired');
|
||||
}
|
||||
|
||||
expect($this->glitchTip->events)->toHaveCount(5);
|
||||
});
|
||||
102
tests/Feature/Monitoring/HealthEndpointTest.php
Normal file
102
tests/Feature/Monitoring/HealthEndpointTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Tiefer Health-Check und Scheduler-Herzschlag (Monitoring-Plan 1.4, 1.5).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('monitoring.health.token', 'geheimes-monitor-token');
|
||||
config()->set('monitoring.health.disk_min_free_percent', 0);
|
||||
config()->set('queue.default', 'database');
|
||||
});
|
||||
|
||||
function healthHeaders(): array
|
||||
{
|
||||
return ['Authorization' => 'Bearer geheimes-monitor-token'];
|
||||
}
|
||||
|
||||
test('the endpoint does not exist without a configured token or with a wrong one', function () {
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->get('/health', healthHeaders())->assertOk();
|
||||
|
||||
$this->get('/health', ['Authorization' => 'Bearer falsch'])->assertNotFound();
|
||||
$this->get('/health')->assertNotFound();
|
||||
|
||||
config()->set('monitoring.health.token', null);
|
||||
$this->get('/health', healthHeaders())->assertNotFound();
|
||||
});
|
||||
|
||||
test('a healthy system answers 200 with every check', function () {
|
||||
config()->set('app.version', 'v1.1.0');
|
||||
$this->artisan('monitoring:heartbeat')->assertSuccessful();
|
||||
|
||||
$this->get('/health?token=geheimes-monitor-token')
|
||||
->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private')
|
||||
->assertJsonPath('status', 'ok')
|
||||
->assertJsonPath('version', 'v1.1.0')
|
||||
->assertJsonPath('checks.database.status', 'ok')
|
||||
->assertJsonPath('checks.cache.status', 'ok')
|
||||
->assertJsonPath('checks.queue.status', 'ok')
|
||||
->assertJsonPath('checks.scheduler.status', 'ok')
|
||||
->assertJsonStructure(['checks' => ['disk' => ['status', 'detail']]]);
|
||||
});
|
||||
|
||||
test('a missing or stale heartbeat makes the endpoint fail', function () {
|
||||
$this->get('/health', healthHeaders())
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('checks.scheduler.status', 'critical');
|
||||
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->travel(4)->minutes();
|
||||
$this->get('/health', healthHeaders())->assertOk()->assertJsonPath('checks.scheduler.status', 'warn');
|
||||
|
||||
$this->travel(7)->minutes();
|
||||
$this->get('/health', healthHeaders())->assertStatus(503)->assertJsonPath('checks.scheduler.status', 'critical');
|
||||
});
|
||||
|
||||
test('a queue backlog older than five minutes makes the endpoint fail', function () {
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
DB::table('jobs')->insert([
|
||||
'queue' => 'default',
|
||||
'payload' => '{}',
|
||||
'attempts' => 0,
|
||||
'reserved_at' => null,
|
||||
'available_at' => now()->subMinutes(6)->getTimestamp(),
|
||||
'created_at' => now()->subMinutes(6)->getTimestamp(),
|
||||
]);
|
||||
|
||||
$this->get('/health', healthHeaders())
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('checks.queue.status', 'critical');
|
||||
});
|
||||
|
||||
test('the endpoint answers on every brand domain and skips basic auth', function (string $domainKey) {
|
||||
config()->set('auth.basic.enabled', true);
|
||||
config()->set('auth.basic.user', 'staging');
|
||||
config()->set('auth.basic.password', 'staging');
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->get('http://'.config($domainKey).'/health', healthHeaders())
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'ok');
|
||||
})->with(['domains.domain_portal', 'domains.domain_presseecho', 'domains.domain_businessportal']);
|
||||
|
||||
test('the admin system health shows scheduler and disk', function () {
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
|
||||
expect($checks->get('scheduler')['status'])->toBe('critical')
|
||||
->and($checks->get('scheduler')['detail'])->toContain('Noch kein Herzschlag')
|
||||
->and($checks)->toHaveKey('disk');
|
||||
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
expect($checks->get('scheduler')['status'])->toBe('ok');
|
||||
});
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
use App\Mail\OperationalAlertMail;
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
|
@ -74,3 +76,57 @@ test('a failing queue job triggers an operational alert via the registered liste
|
|||
return str_contains($mail->summary, 'ClassifyPressRelease');
|
||||
});
|
||||
});
|
||||
|
||||
test('an alert is also pushed to ntfy as json with token and priority', function () {
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
config()->set('monitoring.alerts.mail_to', null);
|
||||
config()->set('monitoring.alerts.ntfy', [
|
||||
'url' => 'https://ntfy.example.com/',
|
||||
'topic' => 'pressekonto-ops',
|
||||
'token' => 'tk_geheim',
|
||||
'priority' => 4,
|
||||
]);
|
||||
Http::fake(['ntfy.example.com/*' => Http::response(['id' => 'abc'])]);
|
||||
|
||||
OperationalAlert::send('Geplanter Task fehlgeschlagen: archive:purge', ['exit_code' => 1, 'queue' => ['a' => 'b']]);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://ntfy.example.com'
|
||||
&& $request->hasHeader('Authorization', 'Bearer tk_geheim')
|
||||
&& $request['topic'] === 'pressekonto-ops'
|
||||
&& $request['priority'] === 4
|
||||
&& str_contains($request['message'], 'archive:purge')
|
||||
&& str_contains($request['message'], 'exit_code: 1')
|
||||
&& str_contains($request['message'], 'queue: {"a":"b"}');
|
||||
});
|
||||
});
|
||||
|
||||
test('a failing ntfy push neither bubbles up nor stops the mail', function () {
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
config()->set('monitoring.alerts.mail_to', 'ops@example.com');
|
||||
config()->set('monitoring.alerts.ntfy.url', 'https://ntfy.example.com');
|
||||
config()->set('monitoring.alerts.ntfy.topic', 'pressekonto-ops');
|
||||
Mail::fake();
|
||||
Http::fake(['ntfy.example.com/*' => Http::response('down', 502)]);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
|
||||
Mail::assertSent(OperationalAlertMail::class);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('no push is sent when alerts are disabled or ntfy is not configured', function () {
|
||||
Http::fake();
|
||||
config()->set('monitoring.alerts.ntfy.url', 'https://ntfy.example.com');
|
||||
config()->set('monitoring.alerts.ntfy.topic', null);
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
|
||||
config()->set('monitoring.alerts.ntfy.topic', 'pressekonto-ops');
|
||||
config()->set('monitoring.alerts.enabled', false);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('the umami snippet stays off until the go-live switch is set', function () {
|
||||
|
|
@ -27,3 +30,78 @@ test('the umami snippet renders the website id of the requested brand', function
|
|||
'businessportal24' => ['businessportal24.test', 'f03a5fc3-ac44-488f-9118-84004861099f'],
|
||||
'pressekonto hub' => ['pressekonto.test', 'ca440b65-a5bd-42c1-8cdb-96f34734003c'],
|
||||
]);
|
||||
|
||||
test('the web snippet carries the query filter, web vitals and no identity', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.identify_users', true);
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->get('https://presseecho.test/de/faq')
|
||||
->assertSuccessful()
|
||||
->assertSee('data-tag="web"', false)
|
||||
->assertSee('data-before-send="pkUmamiBeforeSend"', false)
|
||||
->assertSee('window.pkUmamiBeforeSend', false)
|
||||
->assertSee('data-performance="true"', false)
|
||||
->assertDontSee('umami.identify', false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Kundenbereich (Monitoring-Plan M2, M6, M7).
|
||||
*/
|
||||
function umamiCustomer(): User
|
||||
{
|
||||
test()->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
test('the customer area is tracked only with its own switch', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.portal_enabled', false);
|
||||
$this->actingAs(umamiCustomer());
|
||||
|
||||
$this->get(route('me.dashboard'))->assertSuccessful()->assertDontSee('sys.adametz.media', false);
|
||||
|
||||
config()->set('services.umami.portal_enabled', true);
|
||||
|
||||
$this->get(route('me.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertSee('data-tag="portal"', false)
|
||||
->assertSee('data-website-id="ca440b65-a5bd-42c1-8cdb-96f34734003c"', false)
|
||||
->assertDontSee('umami.identify', false);
|
||||
});
|
||||
|
||||
test('customers are identified by user id only when identify is switched on', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.portal_enabled', true);
|
||||
config()->set('services.umami.identify_users', true);
|
||||
$customer = umamiCustomer();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$this->get(route('me.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertSee("window.umami.identify('user-{$customer->id}')", false)
|
||||
->assertDontSee("identify('{$customer->email}", false);
|
||||
});
|
||||
|
||||
test('the admin area and impersonated sessions are never tracked', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.portal_enabled', true);
|
||||
config()->set('services.umami.identify_users', true);
|
||||
$customer = umamiCustomer();
|
||||
$admin = User::factory()->create(['is_active' => true, 'is_super_admin' => true]);
|
||||
|
||||
$this->actingAs($admin)->get(route('dashboard'))->assertSuccessful()->assertDontSee('sys.adametz.media', false);
|
||||
|
||||
$this->actingAs($customer)
|
||||
->withSession([UserImpersonation::SessionKey => $admin->id])
|
||||
->get(route('me.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('sys.adametz.media', false);
|
||||
});
|
||||
|
|
|
|||
63
tests/Support/FakeGlitchTip.php
Normal file
63
tests/Support/FakeGlitchTip.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Sentry\ClientBuilder;
|
||||
use Sentry\Event;
|
||||
use Sentry\SentrySdk;
|
||||
use Sentry\Transport\Result;
|
||||
use Sentry\Transport\ResultStatus;
|
||||
use Sentry\Transport\TransportInterface;
|
||||
|
||||
/**
|
||||
* Fängt Sentry-Events ab, statt sie an GlitchTip zu senden. Der Client wird
|
||||
* mit den Datenschutz-Optionen aus config/sentry.php gebaut, damit Tests den
|
||||
* echten `before_send`-Weg sehen.
|
||||
*
|
||||
* Nutzung: $glitchTip = FakeGlitchTip::install(); … $glitchTip->events
|
||||
*/
|
||||
class FakeGlitchTip implements TransportInterface
|
||||
{
|
||||
/** @var list<Event> */
|
||||
public array $events = [];
|
||||
|
||||
public static function install(): self
|
||||
{
|
||||
$transport = new self;
|
||||
|
||||
$client = ClientBuilder::create([
|
||||
'dsn' => 'https://public@glitchtip.test/1',
|
||||
'default_integrations' => false,
|
||||
'send_default_pii' => config('sentry.send_default_pii'),
|
||||
'max_request_body_size' => config('sentry.max_request_body_size'),
|
||||
'before_send' => config('sentry.before_send'),
|
||||
])->setTransport($transport)->getClient();
|
||||
|
||||
SentrySdk::getCurrentHub()->bindClient($client);
|
||||
|
||||
return $transport;
|
||||
}
|
||||
|
||||
public function send(Event $event): Result
|
||||
{
|
||||
$this->events[] = $event;
|
||||
|
||||
return new Result(ResultStatus::success(), $event);
|
||||
}
|
||||
|
||||
public function close(?int $timeout = null): Result
|
||||
{
|
||||
return new Result(ResultStatus::success());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()),
|
||||
$this->events,
|
||||
));
|
||||
}
|
||||
}
|
||||
38
tests/Unit/Monitoring/ReleaseTest.php
Normal file
38
tests/Unit/Monitoring/ReleaseTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Support\Monitoring\Release;
|
||||
|
||||
/**
|
||||
* Release-Kennung für GlitchTip (Monitoring-Plan 1.1): SENTRY_RELEASE vor der
|
||||
* beim SFTP-Deploy hochgeladenen Datei RELEASE.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->dir = sys_get_temp_dir().'/release-test-'.uniqid();
|
||||
mkdir($this->dir);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink($this->dir.'/RELEASE');
|
||||
@rmdir($this->dir);
|
||||
});
|
||||
|
||||
test('the configured value wins over the release file', function () {
|
||||
file_put_contents($this->dir.'/RELEASE', "abc1234\n");
|
||||
|
||||
expect(Release::current('v1.2.0', $this->dir))->toBe('v1.2.0');
|
||||
});
|
||||
|
||||
test('the release file is used when nothing is configured', function () {
|
||||
file_put_contents($this->dir.'/RELEASE', "98c465b-dirty\n");
|
||||
|
||||
expect(Release::current(null, $this->dir))->toBe('98c465b-dirty')
|
||||
->and(Release::current(' ', $this->dir))->toBe('98c465b-dirty');
|
||||
});
|
||||
|
||||
test('without file or with garbage there is no release', function () {
|
||||
expect(Release::current(null, $this->dir))->toBeNull();
|
||||
|
||||
file_put_contents($this->dir.'/RELEASE', '<git rev-parse --short HEAD> # bei jedem Deploy');
|
||||
|
||||
expect(Release::current(null, $this->dir))->toBeNull();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue