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:
Kevin Adametz 2026-10-05 12:53:00 +00:00
parent f2ed268b51
commit 8769a71100
42 changed files with 1896 additions and 9 deletions

View file

@ -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();

View 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);
});

View 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');
});

View 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();
});

View 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);
});

View 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');
});

View file

@ -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();
});

View file

@ -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);
});

View 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,
));
}
}

View 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();
});