Compare commits

...

6 commits

Author SHA1 Message Date
0a3eb791d4 PDF: Seitenzahlen auf allen Seiten, Lieferschein-Beschriftung
- page_script in invoice, delivery, credit und cancellation ans Ende des
  body verschoben (wirkte nur auf Seite 1), Beschriftung uebersetzt
- Gutschriften rendern mit enable_php wie Rechnungen
- Lieferschein: Schluessel pdf.delivery_note_no ohne ueberzaehligen Punkt
- Tests: Position des Skripts, alle pdf.*-Schluessel in allen Sprachen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:06:55 +00:00
4274bff9e2 Composer-Sicherheitsupdates, dompdf 3, email-Regel ohne Zeilenumbrueche
- Patch-/Minor-Updates (guzzle, commonmark, flysystem, excel,
  phpspreadsheet, phpseclib, laravel/framework 11.x)
- barryvdh/laravel-dompdf 3.1 (dompdf 3.1.6), Test fuer den PDF-Weg
  inkl. eingebettetem PHP und Briefpapier
- Rueckport des Laravel-12.60-Fixes gegen CRLF in der email-Regel
- Protokoll: Ergebnis composer audit, offene Punkte Laravel 12 und
  PDF-Seitenzahlen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:50:31 +00:00
47a46b1401 Protokoll: GlitchTip live (v1.3.0), Exception-Mails ab 22.10. abschalten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:35:04 +00:00
0b02b1b15c GlitchTip: Fehlerprotokoll fuer Server, Fehlersignale und Browser
- sentry/sentry-laravel 4.28; config/sentry.php gehaertet (keine PII,
  keine Request-Bodys, before_send ErrorReportScrubber)
- Handler: Integration::captureUnhandledException, CommandNotFoundException
  nicht melden
- ReportErrorSignals (global): toter interner Link (nach Pfad wegen
  Catch-all-Route), 403/429 fuer Angemeldete, 419; gedrosselt
- Browser: selbst gehostetes SDK (esbuild-Bundle 11.5.0), Loader ohne
  Build-Schritt und ohne Regex-Lookbehind, in 7 Layouts mit area-Tag
- phpunit.xml: DSNs und ntfy-URL leer, CACHE_DRIVER=array (Tests schrieben
  bisher in den lokalen Datei-Cache)
- dev/glitch-tip/README.md mit Instanz, Org, Projekt-IDs, API-Auswertung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:46:36 +00:00
36feed6324 Protokoll: Uptime Kuma prueft /health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:18:25 +00:00
f20b2ba1c6 Protokoll der Umstellung mit Aufraeum-Liste, Kommentar Execution-Reminder
- dev/betrieb/2026-10-08-protokoll-umstellung.md: Ergebnis, Releases,
  Aufraeumen ab 22.10.2026 (Server, Mac, Git-Server), offene Punkte
- Kernel: abo:send-execution-reminder ist bewusst aktiv (veralteten
  Kommentar entfernt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:00:49 +00:00
43 changed files with 2284 additions and 603 deletions

View file

@ -167,6 +167,12 @@ Live wird per **Git-Tag** deployt (`bash dev/betrieb/deploy.sh vX.Y.Z [--migrati
- Assets (`public/js`, `public/css`, `public/vendor`, `public/assets`) sind versioniert; auf dem Server wird nichts gebaut.
- Version: Datei `RELEASE` → `config('app.version')`, `php artisan about --only=mivita`.
## Überwachung / Fehlerprotokoll
- Fehler laufen nach **GlitchTip** (`https://errors.adametz.media`, Org `adametz-media`, Projekte `server-mivitacare` = 4, `browser-mivitacare` = 3). Auswerten per API mit `GLITCHTIP_API_TOKEN` aus der `.env`: `dev/glitch-tip/README.md`.
- Systemstatus: Sysadmin → Systemstatus bzw. `/health` (Token); Alarme bei fehlgeschlagenen geplanten Aufgaben/Jobs per Mail und ntfy (`App\Support\OperationalAlert`). Details: `dev/betrieb/README.md`.
- **Tests melden nie nach außen** (`phpunit.xml` leert DSNs und ntfy-URL) — beim Anlegen neuer Tests nichts daran ändern.
## Scheduled Tasks (Cron)
| Zeit | Command | Beschreibung |

View file

@ -77,9 +77,7 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(60)
->runInBackground();
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster).
// BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP
// im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren:
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster)
$schedule->command('abo:send-execution-reminder')->dailyAt('06:00');
// Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs)

View file

@ -8,6 +8,8 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Laravel\Passport\Exceptions\OAuthServerException;
use Sentry\Laravel\Integration;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\HttpFoundation\Response;
@ -25,6 +27,8 @@ class Handler extends ExceptionHandler
// PAYONE-Transportfehler werden bereits via MyLog protokolliert (inkl. Mail);
// kein zusätzlicher Exception-Report nötig (entspricht dem alten abort(403)-Verhalten).
PayoneTransportException::class,
// Vertippte artisan-Befehle sind kein Anwendungsfehler
CommandNotFoundException::class,
];
/**
@ -63,6 +67,9 @@ class Handler extends ExceptionHandler
if ($this->shouldReport($exception)) {
$this->sendEmail($exception);
// An GlitchTip (ohne SENTRY_LARAVEL_DSN ein No-op)
Integration::captureUnhandledException($exception);
}
parent::report($exception);
}

View file

@ -19,6 +19,8 @@ class Kernel extends HttpKernel
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\TrustProxies::class,
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) an GlitchTip
\App\Http\Middleware\ReportErrorSignals::class,
# \App\Http\Middleware\RemoveExcessWhitespaceMiddleware::class,
];

View file

@ -0,0 +1,96 @@
<?php
namespace App\Http\Middleware;
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\ErrorSignal;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use function Illuminate\Support\defer;
/**
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
* obwohl keine Exception gemeldet wird:
*
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
* - 419 — Formular abgelaufen (CSRF-Token), Eingaben sind verloren
*
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
*/
class ReportErrorSignals
{
public function __construct(private readonly ErrorSignal $errorSignal) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$signal = $this->signalFor($request, $response->getStatusCode());
if ($signal !== null) {
$location = $this->locationOf($request, $signal);
$context = [
'status' => $response->getStatusCode(),
'method' => $request->method(),
'path' => '/'.ltrim($request->path(), '/'),
'referer' => $this->refererPath($request),
];
defer(fn () => $this->errorSignal->report($signal, $location, $context));
}
return $response;
}
private function signalFor(Request $request, int $status): ?string
{
return match (true) {
$status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink,
$status === 403 && $request->user() !== null => ErrorSignal::AccessDenied,
$status === 419 => ErrorSignal::PageExpired,
$status === 429 && $request->user() !== null => ErrorSignal::Throttled,
default => null,
};
}
/**
* Tote Links nach Pfad: auf der Haupt-Domain fängt die Catch-all-Route
* `/{site}/{subsite?}/{product_slug?}` fast jeden Pfad — mit dem
* Routennamen landeten alle toten Links in einem Eintrag.
*/
private function locationOf(Request $request, string $signal): string
{
if ($signal === ErrorSignal::DeadInternalLink) {
return '/'.ltrim($request->path(), '/');
}
$routeName = $request->route()?->getName();
return $routeName !== null
? $routeName
: '/'.ltrim($request->path(), '/');
}
private function hasInternalReferer(Request $request): bool
{
$referer = $request->headers->get('referer');
return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost();
}
private function refererPath(Request $request): ?string
{
$referer = $request->headers->get('referer');
if (! is_string($referer) || $referer === '') {
return null;
}
$path = parse_url($referer, PHP_URL_PATH);
return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null;
}
}

View file

@ -7,6 +7,7 @@ use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Util;
use App\Support\OperationalAlert;
use App\Support\Validation\Validator as SafeEmailValidator;
use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Pagination\Paginator;
@ -14,6 +15,7 @@ use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -26,6 +28,7 @@ class AppServiceProvider extends ServiceProvider
public function boot()
{
Schema::defaultStringLength(191);
Validator::resolver(fn ($translator, $data, $rules, $messages, $attributes) => new SafeEmailValidator($translator, $data, $rules, $messages, $attributes));
Paginator::defaultView('pagination::bootstrap-4');
Paginator::defaultSimpleView('pagination::simple-bootstrap-4');

View file

@ -102,6 +102,7 @@ class CreditRepository extends BaseRepository
$data['is_copy'] = $is_copy;
$pdf = PDF::loadView('pdf.credit', $data);
$pdf->setOption('enable_php', true);
$pdf->setPaper('A4', 'portrait');
$pdf->save($path.$dir.$filename);

View file

@ -0,0 +1,159 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\UserDataBag;
use Throwable;
/**
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
*
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
* in Query-Strings (signierte Links) und in Pfaden
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
* QueryException mit eingesetzten Bindings) ebenso.
*/
class ErrorReportScrubber
{
public const Filtered = '[Filtered]';
/**
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
* Dieselbe Liste steht in public/js/error-reporting/error-reporting.js.
*
* @var list<string>
*/
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort', 'lang', 'month', 'year'];
private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i';
/** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */
private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#';
public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event
{
$userId = self::authenticatedUserId();
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
$event->setRequest(self::scrubRequest($event->getRequest()));
foreach ($event->getExceptions() as $exception) {
$exception->setValue(self::scrubText($exception->getValue()));
}
if ($event->getMessage() !== null) {
$formatted = $event->getMessageFormatted();
$event->setMessage(
self::scrubText($event->getMessage()),
$event->getMessageParams(),
$formatted !== null ? self::scrubText($formatted) : null,
);
}
return $event;
}
/**
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
public static function scrubRequest(array $request): array
{
if (isset($request['url']) && is_string($request['url'])) {
$request['url'] = self::scrubUrl($request['url']);
}
if (isset($request['query_string']) && is_string($request['query_string'])) {
$request['query_string'] = self::scrubQuery($request['query_string']);
}
unset($request['data'], $request['cookies']);
foreach ($request['headers'] ?? [] as $name => $values) {
if (strtolower((string) $name) === 'referer') {
$request['headers'][$name] = array_map(
static fn (mixed $value): string => self::scrubUrl((string) $value),
(array) $values,
);
}
}
return $request;
}
public static function scrubUrl(string $url): string
{
$query = parse_url($url, PHP_URL_QUERY);
$withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url;
$scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery));
return $query !== null && $query !== false && $query !== ''
? $scrubbedPath.'?'.self::scrubQuery($query)
: $scrubbedPath;
}
public static function scrubQuery(string $query): string
{
parse_str($query, $parameters);
$scrubbed = [];
foreach ($parameters as $name => $value) {
$isAllowed = in_array((string) $name, self::AllowedQueryParameters, true)
|| str_starts_with((string) $name, 'utm_');
$scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered;
}
return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986);
}
public static function scrubPath(string $path): string
{
return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path));
}
public static function scrubText(string $text): string
{
return (string) preg_replace(self::EmailPattern, '[email]', $text);
}
/**
* Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet
* (`token`, `hash`, `signature`), im Pfad der aktuellen Route.
*/
private static function scrubPathTokens(string $url): string
{
try {
$route = request()->route();
} catch (Throwable) {
return $url;
}
if ($route === null) {
return $url;
}
foreach ($route->parameters() as $name => $value) {
if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) {
$url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url));
}
}
return $url;
}
private static function authenticatedUserId(): int|string|null
{
try {
return Auth::id();
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Sentry\Severity;
use Sentry\State\Scope;
use function Sentry\captureMessage;
use function Sentry\withScope;
/**
* Fehler ohne Exception als GlitchTip-Warnung: tote
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
*/
class ErrorSignal
{
public const DeadInternalLink = 'dead_internal_link';
public const AccessDenied = 'access_denied';
public const PageExpired = 'page_expired';
public const Throttled = 'throttled';
private const Labels = [
self::DeadInternalLink => 'Toter interner Link',
self::AccessDenied => 'Zugriff verweigert (angemeldet)',
self::PageExpired => 'Seite abgelaufen (419)',
self::Throttled => 'Drosselung trifft angemeldeten Nutzer',
];
private const MaxReportsPerWindow = 5;
private const WindowSeconds = 600;
/**
* @param array<string, mixed> $context
*/
public function report(string $signal, string $location, array $context = []): bool
{
$location = Str::limit($location, 200, '');
$reported = RateLimiter::attempt(
'error-signal:'.sha1($signal.'|'.$location),
self::MaxReportsPerWindow,
static fn (): bool => true,
self::WindowSeconds,
);
if ($reported === false) {
return false;
}
withScope(function (Scope $scope) use ($signal, $location, $context): void {
$scope->setFingerprint(['error-signal', $signal, $location]);
$scope->setTag('signal', $signal);
$scope->setContext('signal', $context);
captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning());
});
return true;
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Support\Validation;
use Illuminate\Validation\Validator as BaseValidator;
/**
* Rückportierter Sicherheitsfix aus Laravel 12.60 (GHSA-5vg9-5847-vvmq):
* Die email-Regel lässt in Laravel 11 Zeilenumbrüche zu, über die sich
* Mail-Header einschleusen lassen. Nach dem Update auf Laravel 12 entfernen
* (samt Registrierung in AppServiceProvider).
*/
class Validator extends BaseValidator
{
/**
* @param string $attribute
* @param mixed $value
* @param array<int, string> $parameters
*/
public function validateEmail($attribute, $value, $parameters): bool
{
if (is_string($value) || (is_object($value) && method_exists($value, '__toString'))) {
if (preg_match('/[\r\n]/', (string) $value) > 0) {
return false;
}
}
return parent::validateEmail($attribute, $value, $parameters);
}
}

View file

@ -7,7 +7,7 @@
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^2.2",
"barryvdh/laravel-dompdf": "^3.1",
"cocur/slugify": "^4.5",
"cviebrock/eloquent-sluggable": "^11.0",
"doctrine/dbal": "^3.6.0|^4.0",
@ -23,6 +23,7 @@
"laravel/tinker": "^2.9",
"laravel/ui": "^4.2",
"maatwebsite/excel": "^3.1",
"sentry/sentry-laravel": "^4.28",
"setasign/fpdf": "^1.8.6",
"setasign/fpdi": "^2.6",
"spatie/laravel-html": "^3.7",

1381
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -45,6 +45,24 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Browserfehler
|--------------------------------------------------------------------------
|
| Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich
| (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne
| DSN wird kein Skript eingebunden. Das SDK liegt selbst gehostet unter
| public/js/error-reporting/sentry-browser-<sdk_version>.min.js.
|
*/
'browser' => [
'dsn' => env('SENTRY_BROWSER_DSN'),
'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0),
'sdk_version' => '11.5.0',
],
'scheduler' => [
'heartbeat_warn_minutes' => 3,
'heartbeat_critical_minutes' => 10,

180
config/sentry.php Normal file
View file

@ -0,0 +1,180 @@
<?php
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\Release;
/**
* Sentry Laravel SDK configuration file.
*
* Ziel ist das selbst gehostete GlitchTip, das das
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
* Paket-Standard: kein Personenbezug außer der User-ID, keine
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// @see https://spotlightjs.com/
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
// Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw.
// APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE.
'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))),
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
'environment' => env('SENTRY_ENVIRONMENT'),
// Override the organization ID used for trace continuation checks.
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics
'enable_metrics' => env('SENTRY_ENABLE_METRICS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
'send_default_pii' => false,
// Formular- und JSON-Inhalte verlassen den Server nie.
'max_request_body_size' => 'never',
// User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen.
'before_send' => [ErrorReportScrubber::class, 'beforeSend'],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
// 'ignore_exceptions' => [],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
'ignore_transactions' => [
// Ignore Laravel's default health URL
'/up',
'/health',
],
// Breadcrumb specific configuration
'breadcrumbs' => [
// Capture Laravel logs as breadcrumbs
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false),
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false),
// Capture Livewire components like routes as breadcrumbs
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
// Capture SQL queries as breadcrumbs
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
// Capture queue job information as breadcrumbs
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
// Capture command information as breadcrumbs
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
// Capture HTTP client request information as breadcrumbs
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture send notifications as breadcrumbs
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
],
// Performance monitoring specific configuration
'tracing' => [
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
// Capture SQL queries as spans
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query spans
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
// Capture where the SQL query originated from on the SQL query spans
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
// Define a threshold in milliseconds for SQL queries to resolve their origin
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
// Capture views rendered as spans
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
// Capture Livewire components as spans
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
// Capture HTTP client requests as spans
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as spans
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
// Capture where the Redis command originated from on the Redis command spans
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
// Capture send notifications as spans
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
// Enable tracing for requests without a matching route (404's)
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
// Capture AI agent interactions as spans (requires laravel/ai)
'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true),
// Capture AI invoke_agent spans
'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true),
// Capture AI chat spans
'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true),
// Capture AI execute_tool spans
'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true),
// Capture AI embeddings spans
'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true),
// Enable the tracing integrations supplied by Sentry (recommended)
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
],
];

View file

@ -0,0 +1,114 @@
# Protokoll: Umstellung auf Git-Deploy und Betriebsüberwachung (07.–08.10.2026)
**Stand:** 08.10.2026 · Live auf `v1.3.0` · Anleitung für den laufenden Betrieb: [README.md](README.md)
## Ergebnis
| Bereich | Vorher | Jetzt |
|---|---|---|
| Deploy | SFTP mit Dateilisten | Git-Tag, `bash dev/betrieb/deploy.sh vX.Y.Z` (nur auf Live, bricht vor Änderungen ab) |
| Version | unbekannt | Datei `RELEASE`, `php artisan about --only=mivita`, Systemstatus |
| Queue | `QUEUE_DRIVER=sync` | Horizon (Ploi-Daemon), `QUEUE_DRIVER=redis`, `after_commit` |
| Überwachung | keine | Sysadmin → Systemstatus, `/health` (Token) in Uptime Kuma, Scheduler-Herzschlag |
| Alarme | keine | Mail und ntfy-Push bei fehlgeschlagener geplanter Aufgabe oder Queue-Job (1×/h je Meldung) |
| Fehlerprotokoll | Exception-Mail je Fehler | GlitchTip (Server, Fehlersignale, Browser), Alert-Regel: Mail bei neuem Fehlertyp, nur `production` — [dev/glitch-tip/README.md](../glitch-tip/README.md) |
| Git-Repository | 7,2 GB, `.env` und Schlüssel in der Historie | 41 MB, Historie bereinigt |
## Releases
| Tag | Inhalt |
|---|---|
| `v1.0.0` | erster Git-Release: `deploy.sh`, Version aus `RELEASE`, `public/vendor` versioniert (ausgedünnt), Horizon vorbereitet |
| `v1.0.1` | Live-Abgleich: Übersetzungen und Seiten (`partner`, `ueber-uns`, `erreichbarkeit`) von Live übernommen, Laufzeitdateien (`storage/fonts`, `storage/reports`, OAuth-Schlüssel) und `public/phpinfo.php` raus |
| `v1.1.0` | Systemstatus im Sysadmin-Bereich, `/health`, Scheduler-Herzschlag |
| `v1.1.1` | `bootstrap/cache` nicht mehr versioniert |
| `v1.2.0` | Alarm-Mails, kurze `withoutOverlapping`-Sperren |
| `v1.2.1` | bereinigte Git-Historie (alle Hashes neu), IDE-Helper-Dateien nicht mehr versioniert |
| `v1.2.2` | `deploy.sh` nur bei `APP_ENV=production`, `dev/app-bak` entfernt |
| `v1.3.0` | GlitchTip (Server, Fehlersignale, Browser), `sentry/sentry-laravel`; Tests ohne lokalen Datei-Cache |
| `v1.4.0` | Composer-Sicherheitsupdates, dompdf 3, `email`-Regel lehnt Zeilenumbrüche ab; PDF-Seitenzahlen und Lieferschein-Beschriftung |
Alle Tags vor dem 08.10. wurden beim Bereinigen der Historie neu erzeugt; Commit-Hashes in älteren
Notizen unter `dev/` gehören zur alten Historie.
## Erledigt auf dem Server
- Deploy-Key (nur lesen) für `forge.adametz.media`, Host-Key geprüft, FPM-Reload ohne Passwort getestet
- Live-Verzeichnis in einen Git-Checkout umgewandelt; Dateien nur auf Live nach `~/vor-git/` verschoben
- `.git` nach der Historien-Bereinigung neu aufgesetzt
- `.env`: `MONITORING_HEALTH_TOKEN`, `QUEUE_DRIVER=redis`; Zugangsdaten aus der früheren `.env`-Historie getauscht
- Horizon als Ploi-Daemon, Worker-Prüfung grün
- Uptime Kuma prüft `/health` (mit Token)
- `.env`: GlitchTip-DSNs, `SENTRY_ENVIRONMENT=production`, ntfy; Test-Exception und Browserfehler kamen an
- Cron: Ploi-Eintrag in `/etc/cron.d/` (`schedule:run` jede Minute, Benutzer `ploi`) — nicht in `crontab -l`
- Branches `payment/phase-r-payone-refactoring` und `wip/payment-phase-r-payone-refactoring-2026-09-28` gelöscht; gearbeitet wird auf `master`
## Aufräumen — ab 22.10.2026, wenn bis dahin nichts aufgefallen ist
Alles hier sind Sicherungen aus der Umstellung; nichts davon wird von der App benutzt.
**Auf dem Server** (`ploi@mivita-prod`):
```bash
rm -rf ~/git-alt-2026-10-08 # altes .git vor der Bereinigung (7 GB)
rm -rf ~/vor-git # Dateien, die nur auf Live lagen (alte Views, Duplikat-Migrationen, nikki-beach-Bilder, wartung.php, .phpinfo.php, cron_script.sh, Theme-Reste aus public/vendor, IDE-Dateien)
rm -f ~/live-abweichungen.tgz # Archiv für den Abgleich
rm -f ~/backup-vor-git-2026-10-07.tgz # Sicherung des Live-Verzeichnisses vor der Umwandlung
rm -f /home/ploi/mivita.care/storage/logs/scheduler.log # Diagnose-Log vom 07.10.
```
**Auf dem Mac** (im Projektordner, alles unter `_work/`, also nicht versioniert):
```bash
rm -rf _work/git-alt-2026-10-08 # altes .git vor der Bereinigung (7 GB)
rm -rf _work/mivita-clean.git # bereinigte Kopie, aus der gepusht wurde
rm -rf _work/live-abweichungen _work/live-abweichungen.tgz # Abgleich mit Live
rm -rf _work/migrations-duplikate # zwei doppelte Migrationen (Klassen-Konflikt), liegen auch in ~/vor-git
rm -f _work/status-vorher.txt # Git-Status vor dem Austausch von .git
```
**In Forgejo und Gitea (NAS):** Garbage Collection auslösen, falls noch nicht geschehen — erst dann
sind die alten Objekte (`.env`, Schlüssel, 10-GB-Datei) auch auf den Git-Servern weg.
## Noch offen
- **Exception-Mails abschalten** (ab 22.10.2026, nach zwei Wochen Parallelbetrieb mit GlitchTip):
`App\Exceptions\Handler::report()` ruft `sendEmail()` — Aufruf und Methode entfernen,
`emails/exception.blade.php` prüfen, ob sonst noch genutzt. Betriebs-Alarme (`OperationalAlert`) bleiben.
- **Datenschutzerklärung**: Fehlerprotokollierung aufnehmen (User-ID, URL, Browser, Fehlermeldung;
eigener Server; Frist = Aufbewahrung in GlitchTip; berechtigtes Interesse) — neue Erklärung ist in Arbeit.
- **Passport-Schlüssel** (`storage/oauth-*.key`) standen bis 08.10. in der Git-Historie. Falls sie nicht
mit den Zugangsdaten getauscht wurden: `php artisan passport:keys --force` — alle API-Tokens werden ungültig.
- **Laravel 12**: `composer audit` meldet noch 3 Hinweise zu `laravel/framework` — Fixes gibt es nur in
12.x/13.x, Laravel 11 bekommt keine Sicherheitsupdates mehr. Stand 08.10.:
- CRLF in der `email`-Regel (hoch): Fix aus 12.60 rückportiert (`App\Support\Validation\Validator`,
registriert in `AppServiceProvider`, Test `EmailRuleLineBreakTest`) — nach dem Update entfernen.
- Temporäre signierte URLs (mittel): betrifft nur `Storage::temporaryUrl()` auf dem lokalen Disk — im
Code nicht genutzt.
- XSS auf der Debug-Seite (niedrig): nur mit `APP_DEBUG=true`, auf Live aus.
Das Update auf Laravel 12 ist ein eigenes Paket.
### Composer-Sicherheitshinweise (08.10.2026)
`composer audit`: 34 Hinweise in 8 Paketen → 3 in `laravel/framework` (siehe oben).
- Patch-/Minor-Updates: `guzzlehttp/guzzle` 7.15, `league/commonmark` 2.10, `league/flysystem` 3.36,
`maatwebsite/excel` 3.1.70, `phpoffice/phpspreadsheet` 1.30.7, `phpseclib/phpseclib` 3.0.57,
`laravel/framework` (11.x) und Abhängigkeiten.
- `barryvdh/laravel-dompdf` 2.2 → 3.1 (dompdf 2.0 → 3.1). Rechnung, Lieferschein und Gutschrift-Details
vorher/nachher verglichen: identisch. `enable_remote` ist jetzt standardmäßig aus — die PDF-Vorlagen
laden Schriften lokal aus `public/fonts`, Bilder per URL gibt es nicht. Test: `tests/Feature/Pdf`.
- Testsuite vorher und nachher: dieselben 88 vorbestehenden Fehlschläge, keine neuen.
Zwei ältere PDF-Fehler, beim Vergleich gefunden und mit behoben:
- **Seitenzahlen**: `page_script` wirkt nur auf Seiten, die beim Aufruf schon gerendert sind. In
`pdf/invoice`, `delivery`, `credit` und `cancellation` stand es am Anfang des `<body>` — „Seite x/y“
erschien deshalb nie. Jetzt am Ende wie in `credit_details`, Beschriftung übersetzt (`pdf.page`).
Gutschriften (`CreditRepository::createCreditPDF`) rendern jetzt auch mit `enable_php`, wie
Rechnungen und Gutschrift-Details.
- **Lieferschein**: zeigte `pdf.delivery_note_no.:` statt „Lieferschein-Nr.:“ (Punkt zu viel im
Schlüssel).
Tests prüfen beides für alle Vorlagen und Sprachen (`tests/Feature/Pdf/PdfPipelineTest.php`).

View file

@ -1,6 +1,7 @@
# Betrieb mivita.care — Git-Deploy per Tag
**Stand:** 07.10.2026 · Vorlage und Hintergründe: [`dev/git-deploy-monitoring/`](../git-deploy-monitoring/README.md)
**Stand:** 08.10.2026 · Vorlage und Hintergründe: [`dev/git-deploy-monitoring/`](../git-deploy-monitoring/README.md) ·
Protokoll der Umstellung mit Aufräum-Liste: [2026-10-08-protokoll-umstellung.md](2026-10-08-protokoll-umstellung.md)
Ausgeliefert wird ein **Git-Tag** (`vX.Y.Z`), nie ein Branch-Stand und keine Dateiliste per SFTP.
Das Live-Verzeichnis ist ein Git-Checkout dieses Tags; [`deploy.sh`](deploy.sh) führt den Deploy
@ -9,7 +10,7 @@ von Hand per SSH aus und bricht ab, **bevor** es etwas verändert, wenn etwas ni
| | Wert |
|---|---|
| Server | Ploi, Benutzer `ploi`, PHP-FPM 8.4 (`php8.4-fpm`), Redis lokal |
| Queue | Horizon als Ploi-Daemon, `QUEUE_DRIVER=redis` (bis zur Umstellung F: `sync`) |
| Queue | Horizon als Ploi-Daemon, `QUEUE_DRIVER=redis` (aktiv seit 08.10.2026) |
| Live-Verzeichnis | `/home/ploi/mivita.care` |
| Git-Server (Deploy liest hier) | `origin` = `ssh://git@forge.adametz.media:222/kadmin/mivita.git` |
| Release-Branch | `master` — Tags werden auf `master` gesetzt |
@ -244,6 +245,11 @@ wirklich läuft. Steht er nach dem Deploy auf „Kritisch“, ist in Ploi kein S
Schwellen in `.env` (optional): `MONITORING_QUEUE_BACKLOG_SECONDS=300`, `MONITORING_DISK_MIN_FREE_PERCENT=10`.
**Fehlerprotokoll GlitchTip:** Exceptions, Fehlersignale (toter interner Link, 403/429, 419) und
Browserfehler — Einrichtung, Werte und Auswertung in [`dev/glitch-tip/README.md`](../glitch-tip/README.md).
Live-`.env`: `SENTRY_LARAVEL_DSN`, `SENTRY_BROWSER_DSN`, `SENTRY_ENVIRONMENT=production`,
`MONITORING_NTFY_URL`, `MONITORING_NTFY_TOPIC`, `MONITORING_NTFY_TOKEN`.
**Alarm-Mails** (`App\Support\OperationalAlert`): Schlägt eine geplante Aufgabe fehl (Exit-Code ≠ 0,
z. B. `business:store-optimized`, `user:make_abo_order`) oder ein Queue-Job endgültig, geht eine Mail an
`MONITORING_ALERT_EMAIL` (Standard: `EXCEPTION_MAIL`). Gleiche Meldungen höchstens einmal pro Stunde

92
dev/glitch-tip/README.md Normal file
View file

@ -0,0 +1,92 @@
# GlitchTip — Fehlerprotokoll mivita.care
**Stand:** 08.10.2026 · Hintergrund und Regeln: [`dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md`](../git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md)
| | Wert |
|---|---|
| Instanz | `https://errors.adametz.media` (selbst gehostet, gemeinsam mit anderen Projekten) |
| Organisation | `adametz-media` |
| Projekt Server (PHP/Laravel) | `server-mivitacare`, ID **4** → `SENTRY_LARAVEL_DSN` |
| Projekt Browser (JavaScript) | `browser-mivitacare`, ID **3** → `SENTRY_BROWSER_DSN` |
| Umgebungen | `production` (Live), `local` (Entwicklung, nur zum Testen) |
| Push (ntfy) | `https://push.adametz.media`, Topic in `MONITORING_NTFY_TOPIC` |
| Alert-Regeln | beide Projekte: Mail bei neuem Fehlertyp, nur `production` |
| Live seit | `v1.3.0` (08.10.2026) |
## Was gemeldet wird
| Quelle | Wie |
|---|---|
| Exceptions (Request, Job, Command) | `App\Exceptions\Handler::report()` → `Integration::captureUnhandledException()`; Laravels `dontReport` gilt (404, Validierung, Auth), zusätzlich `PayoneTransportException`, `CommandNotFoundException` |
| Fehler ohne Exception | `App\Http\Middleware\ReportErrorSignals` (global): 404 mit eigenem Referrer (nach **Pfad**, nicht Route — wegen der Catch-all-Route), 403/429 für Angemeldete, 419; höchstens 5 je 10 Min. und Ort |
| Browserfehler | `public/js/error-reporting/error-reporting.js` über `<x-monitoring.browser-errors area="…" />` in 7 Layouts (`crm`, `crm-auth`, `portal`, `portal-auth`, `portal-public`, `web`, `shop`) |
| Betriebs-Alarme | `App\Support\OperationalAlert` → Log, Mail, ntfy (siehe `dev/betrieb/README.md`, Abschnitt G) |
Datenschutz: `App\Support\Monitoring\ErrorReportScrubber` (Server) und dieselben Regeln in
`error-reporting.js` (Browser): nur User-ID, keine Request-Bodys/Cookies, Tokens und nicht erlaubte
Query-Parameter als `[Filtered]`, E-Mail-Adressen als `[email]`. Erlaubte Query-Parameter:
`page, q, tab, sort, lang, month, year` (+ `utm_*`) — Liste an beiden Stellen gleich halten.
Tests melden nie an das echte GlitchTip/ntfy (`phpunit.xml`, Wächtertest in `ErrorReportingTest`).
## Browser-SDK (selbst gehostet)
Kein Drittanbieter-CDN (sonst ginge die IP jedes Besuchers an Sentry). `public/js/error-reporting/`:
- `error-reporting.js` — Listener, lädt das SDK erst beim ersten Fehler; ES2018, **ohne Regex-Lookbehind**
(ältere Safari-Versionen brechen sonst beim Parsen ab — Test prüft das)
- `sentry-browser-<version>.min.js` — Version in `config('monitoring.browser.sdk_version')`
Neu bauen (z. B. Update auf eine neue SDK-Version), außerhalb des Projekts:
```bash
mkdir /tmp/sentry-build && cd /tmp/sentry-build && npm init -y >/dev/null
npm install --ignore-scripts --save-exact @sentry/browser@<version> esbuild
echo "export { captureException, init, setTag, setUser } from '@sentry/browser';" > entry.js
npx esbuild entry.js --bundle --minify --format=iife --global-name=MivitaSentry --target=es2018 \
--legal-comments=none --outfile=sentry-browser-<version>.min.js
```
Datei nach `public/js/error-reporting/` kopieren, `sdk_version` in `config/monitoring.php` anpassen,
alte Datei löschen.
## Auswerten per API
Lese-Token (`project:read`, `event:read`) liegt lokal als `GLITCHTIP_API_TOKEN` in der `.env` — nie
ausgeben, nie committen.
```bash
T=$(grep -E "^GLITCHTIP_API_TOKEN=" .env | cut -d= -f2- | tr -d "'\"")
B=https://errors.adametz.media/api/0
# Offene Einträge, nur Live (project=4 Server, project=3 Browser)
curl -s -H "Authorization: Bearer $T" \
"$B/organizations/adametz-media/issues/?project=4&query=is:unresolved&environment=production&limit=100" \
| python3 -c "
import json,sys
for i in json.load(sys.stdin):
print(i['id'], i['count'], i['firstSeen'][:16], i['lastSeen'][:16], '|', (i['title'] or '')[:100])"
# Letztes Ereignis eines Eintrags (Umgebung, Release, Bereich, Stacktrace)
curl -s -H "Authorization: Bearer $T" "$B/issues/<ID>/events/latest/"
```
Einordnen und Vorgehen: Abschnitt 7 in `02-fehlerprotokoll-glitchtip.md`. Auflösen in GlitchTip von
Hand (Token ist nur lesend).
## Testen
```bash
# Server (Umgebung laut SENTRY_ENVIRONMENT)
php artisan tinker --execute 'report(new RuntimeException("GlitchTip-Test"));'
# Alarm (Mail + ntfy)
php artisan tinker --execute 'App\Support\OperationalAlert::send("Test-Alarm");'
```
Browser (Konsole auf einer Seite der App, z. B. CRM):
```js
setTimeout(() => { throw new Error('Browser-Test test@example.com') });
```
→ erscheint im Projekt `browser-mivitacare`, E-Mail als `[email]`.

View file

@ -27,6 +27,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<!-- config/cache.php liest CACHE_DRIVER — sonst schreiben Tests in den lokalen Datei-Cache -->
<env name="CACHE_DRIVER" value="array" force="true"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<server name="DB_CONNECTION" value="sqlite" force="true"/>
@ -38,6 +40,11 @@
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="VIES_ENABLED" value="false"/>
<env name="BASIC_AUTH_ENABLED" value="false"/>
<!-- Tests provozieren Fehler absichtlich — nie an das echte GlitchTip -->
<env name="SENTRY_LARAVEL_DSN" value="" force="true"/>
<env name="SENTRY_DSN" value="" force="true"/>
<env name="SENTRY_BROWSER_DSN" value="" force="true"/>
<env name="MONITORING_NTFY_URL" value="" force="true"/>
<env name="PAYONE_KEY_HASH" value="698fb2555f8b2efc74f60b2121421f45"/>
<env name="STRIPE_SECRET" value="sk_test_fake_suite_key"/>
<env name="STRIPE_KEY" value="pk_test_fake_suite_key"/>

View file

@ -0,0 +1,155 @@
/**
* Browserfehler an GlitchTip (ohne Build-Schritt, ES2018).
*
* Fehlerfreie Seiten laden nur diese Datei. Das Sentry-SDK (selbst gehostet,
* sentry-browser-<version>.min.js) wird erst beim ersten Fehler nachgeladen
* und meldet dann genau diesen Fehler. Konfiguration kommt aus
* <meta name="mivita-error-reporting">, das die Blade-Komponente
* x-monitoring.browser-errors setzt.
*
* Bewusst ohne Regex-Lookbehind: ältere Safari-Versionen brechen sonst beim
* Parsen ab.
*/
(function () {
'use strict';
var meta = document.querySelector('meta[name="mivita-error-reporting"]');
var config = null;
try {
config = meta ? JSON.parse(meta.content) : null;
} catch (e) {
config = null;
}
if (!config || !config.dsn || !config.sdk) {
return;
}
// wie App\Support\Monitoring\ErrorReportScrubber::AllowedQueryParameters
var ALLOWED_QUERY = ['page', 'q', 'tab', 'sort', 'lang', 'month', 'year'];
var TOKEN_SEGMENT = /^[A-Za-z0-9_-]{32,}$/;
var EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
/** Gleiche Regeln wie ErrorReportScrubber auf dem Server. */
function scrubUrl(value) {
try {
var url = new URL(value, window.location.origin);
url.pathname = url.pathname
.split('/')
.map(function (segment) {
return TOKEN_SEGMENT.test(segment) ? '[Filtered]' : segment;
})
.join('/');
Array.from(url.searchParams.keys()).forEach(function (key) {
if (ALLOWED_QUERY.indexOf(key) === -1 && key.indexOf('utm_') !== 0) {
url.searchParams.set(key, '[Filtered]');
}
});
url.hash = '';
return url.toString().replace(EMAIL, '[email]');
} catch (e) {
return '[Filtered]';
}
}
var sdk = null;
function loadSdk() {
if (sdk) {
return sdk;
}
sdk = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = config.sdk;
script.async = true;
script.onload = function () {
resolve(window.MivitaSentry);
};
script.onerror = reject;
document.head.appendChild(script);
}).then(function (Sentry) {
Sentry.init({
dsn: config.dsn,
release: config.release || undefined,
environment: config.environment,
sampleRate: typeof config.sampleRate === 'number' ? config.sampleRate : 1,
sendDefaultPii: false,
// Eigene Listener unten statt GlobalHandlers (sonst doppelt);
// keine Breadcrumbs (Klicktexte, URLs) und keine Sessions.
integrations: function (defaults) {
return defaults.filter(function (integration) {
return ['GlobalHandlers', 'Breadcrumbs', 'BrowserSession', 'BrowserApiErrors'].indexOf(integration.name) === -1;
});
},
ignoreErrors: ['ResizeObserver loop', 'Script error.', 'AbortError'],
denyUrls: [/^chrome-extension:\/\//, /^moz-extension:\/\//, /^safari-(web-)?extension:\/\//],
beforeSend: function (event) {
if (event.request && event.request.url) {
event.request.url = scrubUrl(event.request.url);
}
if (event.request && event.request.headers && event.request.headers.Referer) {
event.request.headers.Referer = scrubUrl(event.request.headers.Referer);
}
((event.exception && event.exception.values) || []).forEach(function (exception) {
if (exception.value) {
exception.value = exception.value.replace(EMAIL, '[email]');
}
});
return event;
},
});
if (config.userId) {
Sentry.setUser({ id: String(config.userId) });
}
Sentry.setTag('area', config.area);
return Sentry;
});
return sdk;
}
/**
* Promise-Ablehnungen mit Objekt statt Error kämen als „Error: [object Object]“
* an — Typ und Inhalt mitschicken, damit die Quelle erkennbar ist.
*/
function rejectionToError(reason) {
if (reason instanceof Error) {
return reason;
}
var kind = (reason && reason.constructor && reason.constructor.name) || typeof reason;
var text;
try {
text = typeof reason === 'string' ? reason : JSON.stringify(reason);
} catch (e) {
text = String(reason);
}
return new Error('Unbehandelte Ablehnung (' + kind + '): ' + String(text == null ? '' : text).slice(0, 300));
}
function capture(error) {
loadSdk()
.then(function (Sentry) {
Sentry.captureException(error);
})
.catch(function () {});
}
window.addEventListener('error', function (event) {
// Ressourcenfehler (Bild, Skript) haben kein `error` — nur echte Skriptfehler.
if (event.error) {
capture(event.error);
}
});
window.addEventListener('unhandledrejection', function (event) {
capture(rejectionToError(event.reason));
});
})();

File diff suppressed because one or more lines are too long

View file

@ -17,6 +17,7 @@ return [
'reverse_charge_procedure_info' => 'Reverse-Charge-Verfahren, Umkehr der Steuerschuldnerschaft.',
'delivery_note_no' => 'Lieferschein-Nr.',
'order_no' => 'Bestell-Nr.',
'page' => 'Seite',
'delivery_note' => 'LIEFERSCHEIN',
'we_are_always_there_for_questions' => 'Bei Fragen sind wir jederzeit für Dich da.',
'your_advisor' => 'Dein Berater',

View file

@ -20,6 +20,7 @@ return [
'net_amount' => 'net amounts',
'off' => 'from',
'order_no' => 'order no.',
'page' => 'Page',
'payment_type' => 'payment method',
'points' => 'points',
'points_order' => 'points order',

View file

@ -21,6 +21,7 @@ return [
'net_amount' => 'importes netos',
'off' => 'fuera de',
'order_no' => 'N º de pedido.',
'page' => 'Página',
'payment_type' => 'método de pago',
'points' => 'puntos',
'points_order' => 'orden de puntos',

View file

@ -17,6 +17,7 @@ return [
'reverse_charge_procedure_info' => 'Procédure d’autoliquidation, inversion de la dette fiscale.',
'delivery_note_no' => 'N° bon de livraison',
'order_no' => 'N° commande',
'page' => 'Page',
'delivery_note' => 'BON DE LIVRAISON',
'we_are_always_there_for_questions' => 'Nous sommes toujours à votre disposition pour toute question.',
'your_advisor' => 'Votre conseiller',

View file

@ -0,0 +1,15 @@
@props(['area' => 'web'])
{{-- Browserfehler an GlitchTip. Ohne SENTRY_BROWSER_DSN wird nichts eingebunden;
das SDK selbst lädt erst beim ersten Fehler (public/js/error-reporting/). --}}
@if (filled(config('monitoring.browser.dsn')))
<meta name="mivita-error-reporting" content="{{ json_encode([
'dsn' => config('monitoring.browser.dsn'),
'sdk' => asset('js/error-reporting/sentry-browser-'.config('monitoring.browser.sdk_version').'.min.js'),
'release' => config('sentry.release'),
'environment' => config('sentry.environment') ?? app()->environment(),
'sampleRate' => config('monitoring.browser.sample_rate'),
'userId' => auth()->id(),
'area' => $area,
]) }}">
<script src="{{ asset('js/error-reporting/error-reporting.js') }}?v={{ config('app.version') ?? config('monitoring.browser.sdk_version') }}" defer></script>
@endif

View file

@ -54,6 +54,7 @@
<link rel="stylesheet" type="text/css" href="{{ asset('/css/cookieconsent.min.css') }}" />
<script src="{{ asset('/js/jquery.min.js') }}"></script>
<x-monitoring.browser-errors area="crm" />
</head>
<body>

View file

@ -67,6 +67,7 @@
})});
</script>
<x-monitoring.browser-errors area="crm-auth" />
</head>
<body>

View file

@ -268,18 +268,6 @@
</head>
<body>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = "Seite: ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
<div id="address_box">
<div id="address_box_top">{{ __('pdf.address_top') }} </div>
@if($shopping_order->shopping_user->billing_company)
@ -417,5 +405,17 @@
@endif
</div>
</div>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = __("pdf.page").": ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
</body>
</html>

View file

@ -258,18 +258,6 @@
</head>
<body>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = "Seite: ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
<div id="address_box">
<div id="address_box_top">{{ __('pdf.address_top') }} </div>
@if($user->account)
@ -418,5 +406,17 @@
</div>
</div>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = __("pdf.page").": ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
</body>
</html>

View file

@ -262,18 +262,6 @@
</head>
<body>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = "Seite: ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
<div id="address_box">
<div id="address_box_top">{{ __('pdf.address_top') }}</div>
@if($shopping_order->shopping_user->same_as_billing)
@ -362,7 +350,7 @@
<td class="text-right">{{ $invoice_date }}</td>
</tr>
<tr>
<td class="text-left">{{ __('pdf.delivery_note_no.') }}:</td>
<td class="text-left">{{ __('pdf.delivery_note_no') }}:</td>
<td class="text-right">{{ $invoice_number }}</td>
</tr>
<tr>
@ -423,5 +411,17 @@
</div>
</div>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = __("pdf.page").": ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
</body>
</html>

View file

@ -258,18 +258,6 @@
</head>
<body>
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = "Seite: ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
<div id="address_box">
<div id="address_box_top">{{ __('pdf.address_top') }} </div>
@if($shopping_order->shopping_user->billing_company)
@ -431,5 +419,17 @@
<div style="page-break-after: always"></div>
@include('pdf.invoice-journal-collection')
@endif
<script type="text/php">
if ( isset($pdf) ) {
$pdf->page_script('
if ($PAGE_COUNT > 1) {
$pageText = __("pdf.page").": ".$PAGE_NUM . "/" . $PAGE_COUNT;
$y = 40 * 3;
$x = 170 * 3;
$pdf->text($x, $y, $pageText, null, 9);
}
');
}
</script>
</body>
</html>

View file

@ -79,6 +79,7 @@
</style>
<x-monitoring.browser-errors area="portal" />
</head>
<body>

View file

@ -67,6 +67,7 @@
})});
</script>
<x-monitoring.browser-errors area="portal-auth" />
</head>
<body>

View file

@ -28,6 +28,7 @@
<script src="{{ asset('/vendor/js/layout-helpers.js') }}"></script>
<script src="{{ asset('/js/jquery.min.js') }}"></script>
<x-monitoring.browser-errors area="portal-public" />
</head>
<body>

View file

@ -88,6 +88,7 @@
color: #fff !important;
}
</style>
<x-monitoring.browser-errors area="web" />
</head>
<body class="smoothscroll enable-animation">

View file

@ -119,6 +119,7 @@
border-color: #989a9c;
}
</style>
<x-monitoring.browser-errors area="shop" />
</head>
<body class="smoothscroll enable-animation">

View file

@ -0,0 +1,60 @@
<?php
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Browserfehler: Meta-Konfiguration und Skript nur mit DSN; die User-ID ist
* der einzige Personenbezug. Das SDK ist selbst gehostet (kein Drittanbieter).
*/
test('nothing is rendered without a browser dsn', function () {
config()->set('monitoring.browser.dsn', null);
expect(trim(Blade::render('<x-monitoring.browser-errors />')))->toBe('');
});
test('the configuration carries dsn, release, sdk and the user id only', function () {
config()->set('monitoring.browser.dsn', 'https://public@glitchtip.test/2');
config()->set('sentry.release', 'v1.3.0');
$user = User::forceCreate(['email' => 'kunde@example.com', 'password' => bcrypt('secret'), 'lang' => 'de']);
$this->actingAs($user);
$html = Blade::render('<x-monitoring.browser-errors area="portal" />');
preg_match('/<meta name="mivita-error-reporting" content="([^"]+)"/', $html, $match);
$config = json_decode(html_entity_decode($match[1]), true);
expect($config)->toMatchArray([
'dsn' => 'https://public@glitchtip.test/2',
'release' => 'v1.3.0',
'userId' => $user->id,
'area' => 'portal',
])
->and($config['sdk'])->toEndWith('/js/error-reporting/sentry-browser-'.config('monitoring.browser.sdk_version').'.min.js')
->and($html)->toContain('js/error-reporting/error-reporting.js')
->and($html)->not->toContain('kunde@example.com');
});
test('the self-hosted sdk and the loader exist and the loader avoids regex lookbehind', function () {
$loader = file_get_contents(public_path('js/error-reporting/error-reporting.js'));
expect(public_path('js/error-reporting/sentry-browser-'.config('monitoring.browser.sdk_version').'.min.js'))->toBeFile()
->and($loader)->not->toContain('(?<=')
->and($loader)->not->toContain('(?<!');
});
test('every page layout includes the browser error reporting', function (string $layout) {
expect(file_get_contents(resource_path("views/{$layout}.blade.php")))->toContain('<x-monitoring.browser-errors');
})->with([
'layouts/application',
'layouts/auth',
'portal/layouts/application',
'portal/layouts/auth',
'portal/layouts/public',
'web/layouts/application',
'web/user/layouts/application',
]);

View file

@ -0,0 +1,104 @@
<?php
use App\Support\Monitoring\ErrorReportScrubber;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Sentry\Event;
use Sentry\ExceptionDataBag;
use Sentry\SentrySdk;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Tests\Support\FakeGlitchTip;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* GlitchTip-Anbindung: gemeldete Exceptions gehen raus, aber nur mit
* User-ID, ohne Tokens und ohne E-Mail-Adressen.
*/
beforeEach(function () {
config()->set('app.exception_mail', null);
});
test('reported exceptions reach glitchtip with the user id only', function () {
$glitchTip = FakeGlitchTip::install();
$user = User::forceCreate(['email' => 'kunde@example.com', 'password' => bcrypt('secret'), 'lang' => 'de']);
$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('a mistyped artisan command is not reported', function () {
$glitchTip = FakeGlitchTip::install();
report(new CommandNotFoundException('Command "config:cachels" is not defined.'));
expect($glitchTip->events)->toBe([]);
});
test('request data loses tokens, signatures and bodies', function () {
$scrubbed = ErrorReportScrubber::scrubRequest([
'url' => 'https://my.mivita.care/einladung/5?signature=abc123&expires=1700000000&page=2',
'query_string' => 'signature=abc123&expires=1700000000&page=2&utm_source=nl',
'data' => ['password' => 'geheim'],
'cookies' => ['mivita_session' => 'x'],
'headers' => ['referer' => ['https://my.mivita.care/password/reset/'.str_repeat('a', 64).'?email=kunde@example.com']],
]);
expect($scrubbed['url'])->toBe('https://my.mivita.care/einladung/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2')
->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2&utm_source=nl')
->and($scrubbed)->not->toHaveKeys(['data', 'cookies'])
->and($scrubbed['headers']['referer'][0])->toBe('https://my.mivita.care/password/reset/[Filtered]?email=%5BFiltered%5D');
});
test('route parameters named like secrets are removed from the url', function () {
Route::middleware('web')->get('/_test/monitoring/magic/{token}', function () {
$event = Event::createEvent();
$event->setRequest(['url' => request()->fullUrl()]);
return ErrorReportScrubber::beforeSend($event)?->getRequest()['url'];
});
$this->get('/_test/monitoring/magic/kurz123')
->assertSuccessful()
->assertSee('/_test/monitoring/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();
});
test('the test suite never reports to the real glitchtip or ntfy', function () {
// Ohne diese Absicherung landeten absichtlich provozierte Fehler
// („Queue weg“, „Datenbank nicht erreichbar“) in GlitchTip bzw. als Push.
expect(config('sentry.dsn'))->toBeEmpty()
->and(config('monitoring.browser.dsn'))->toBeEmpty()
->and(config('monitoring.alerts.ntfy.url'))->toBeEmpty()
->and(SentrySdk::getCurrentHub()->getClient()?->getOptions()->getDsn())->toBeNull();
});

View file

@ -0,0 +1,73 @@
<?php
use App\Support\Monitoring\ErrorSignal;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\Support\FakeGlitchTip;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Fehler ohne Exception: tote interne Links, 403/429 für Angemeldete und
* 419 werden als GlitchTip-Warnung gemeldet. Testrouten mit vier Segmenten,
* sonst greift die Catch-all-Route der Haupt-Domain.
*/
beforeEach(function () {
config()->set('cache.default', 'array');
$this->withoutDefer();
$this->glitchTip = FakeGlitchTip::install();
Route::middleware('web')->get('/_test/monitoring/signal/forbidden', fn () => abort(403))->name('test.forbidden');
Route::middleware('web')->get('/_test/monitoring/signal/expired', fn () => abort(419))->name('test.expired');
Route::middleware('web')->get('/_test/monitoring/signal/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/mehr', ['referer' => url('/produkte/aloe')])->assertNotFound();
expect($this->glitchTip->messages())->toBe(['Toter interner Link: /gibt/es/nicht/mehr']);
$event = $this->glitchTip->events[0];
expect($event->getTags())->toMatchArray(['signal' => ErrorSignal::DeadInternalLink])
->and($event->getFingerprint())->toBe(['error-signal', ErrorSignal::DeadInternalLink, '/gibt/es/nicht/mehr'])
->and($event->getContexts()['signal']['referer'])->toBe('/produkte/aloe');
});
test('a 404 without referer or from another site is not reported', function () {
$this->get('/gibt/es/nicht/mehr')->assertNotFound();
$this->get('/gibt/es/nicht/mehr', ['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/monitoring/signal/forbidden')->assertForbidden();
$this->get('/_test/monitoring/signal/throttled')->assertTooManyRequests();
expect($this->glitchTip->events)->toBe([]);
$this->actingAs(User::forceCreate(['email' => 'berater@example.com', 'password' => bcrypt('secret'), 'lang' => 'de']));
$this->get('/_test/monitoring/signal/forbidden')->assertForbidden();
$this->get('/_test/monitoring/signal/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', function () {
$this->get('/_test/monitoring/signal/expired')->assertStatus(419);
expect($this->glitchTip->messages())->toBe(['Seite abgelaufen (419): test.expired']);
});
test('each signal group is capped at five reports per window', function () {
foreach (range(1, 7) as $attempt) {
$this->get('/_test/monitoring/signal/expired');
}
expect($this->glitchTip->events)->toHaveCount(5);
});

View file

@ -57,6 +57,7 @@ function statusPageUser(int $admin): User
test('system admins see the checks, the version and the menu entry', function () {
config()->set('app.version', 'v1.0.2');
config()->set('monitoring.health.token', null);
app(HealthProbe::class)->recordHeartbeat();
$this->actingAs(statusPageUser(4))

View file

@ -0,0 +1,78 @@
<?php
use App\Libraries\InvoicePDF;
use App\Libraries\MyPDFMerger;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use setasign\Fpdi\Fpdi;
use Tests\TestCase;
uses(TestCase::class);
/**
* Rechnungen, Lieferscheine und Gutschriften laufen über dompdf mit
* eingebettetem PHP (Seitenzahlen per page_script/page_text) und werden
* anschließend mit FPDI auf das Briefpapier gelegt. Schützt diesen Weg bei
* Updates von dompdf oder FPDI.
*
* page_script wirkt nur auf Seiten, die beim Aufruf schon gerendert sind —
* das Skript muss deshalb am Ende des <body> stehen (wie in credit_details).
*/
beforeEach(function () {
$this->pdfDir = storage_path('framework/testing/pdf-pipeline-'.uniqid()).'/';
File::ensureDirectoryExists($this->pdfDir);
View::addLocation($this->pdfDir);
File::put($this->pdfDir.'pdf-probe.blade.php', <<<'BLADE'
<html><body>
<p>Seite eins</p>
<div style="page-break-before: always;">Seite zwei</div>
<script type="text/php">
if (isset($pdf)) {
$pdf->page_script('$GLOBALS["pdfProbePages"][] = $PAGE_NUM."/".$PAGE_COUNT;');
}
</script>
</body></html>
BLADE);
$GLOBALS['pdfProbePages'] = [];
});
afterEach(function () {
File::deleteDirectory($this->pdfDir);
unset($GLOBALS['pdfProbePages']);
});
test('embedded php runs on every page of an invoice pdf', function () {
$file = (new InvoicePDF('pdf-probe'))->create([], 'probe.pdf', 'save', $this->pdfDir);
expect($file)->toBe($this->pdfDir.'probe.pdf')
->and(file_get_contents($file))->toStartWith('%PDF')
->and($GLOBALS['pdfProbePages'])->toBe(['1/2', '2/2']);
});
test('page numbers are added at the end of the template so they reach every page', function (string $template) {
$view = file_get_contents(resource_path("views/pdf/{$template}.blade.php"));
expect(substr_count($view, 'type="text/php"'))->toBe(1)
->and($view)->toMatch('#</script>\s*</body>#');
})->with(['invoice', 'delivery', 'credit', 'cancellation', 'credit_details', 'credit_details_long']);
test('every pdf translation key used in the templates exists', function (string $locale) {
$keys = collect(File::files(resource_path('views/pdf')))
->flatMap(fn ($file) => preg_match_all("/__\\('(pdf\\.[^']+)'/", $file->getContents(), $matches) ? $matches[1] : [])
->unique();
expect($keys)->not->toBeEmpty()
->and($keys->reject(fn ($key) => trans()->hasForLocale($key, $locale))->values()->all())->toBe([]);
})->with(['de', 'en', 'es', 'fr']);
test('the rendered pdf can be laid onto the letterhead template', function () {
$file = (new InvoicePDF('pdf-probe'))->create([], 'probe.pdf', 'save', $this->pdfDir);
$merged = (new MyPDFMerger)->addPDF($file)->myMerge('string', 'probe.pdf', 'template_invoice_de');
File::put($this->pdfDir.'merged.pdf', $merged);
expect($merged)->toStartWith('%PDF')
->and((new Fpdi)->setSourceFile($this->pdfDir.'merged.pdf'))->toBe(2);
});

View file

@ -0,0 +1,33 @@
<?php
use App\Support\Validation\Validator as SafeEmailValidator;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
/**
* Rückportierter Fix aus Laravel 12.60 (GHSA-5vg9-5847-vvmq): Adressen mit
* Zeilenumbruch sind nach RFC teils gültig, ermöglichen aber das Einschleusen
* von Mail-Headern. Die email-Regel muss sie in jeder Variante ablehnen.
*/
test('the application uses the validator with the line break check', function () {
expect(Validator::make([], []))->toBeInstanceOf(SafeEmailValidator::class);
});
test('addresses with line breaks are rejected', function (string $email, string $rule) {
expect(Validator::make(['email' => $email], ['email' => $rule])->fails())->toBeTrue();
})->with([
'quoted local part' => "\"a\r\n b\"@example.com",
'folded local part' => "a\r\n @example.com",
'comment' => "(c\r\n )a@example.com",
'bare line feed' => "\"a\n b\"@example.com",
])->with(['email', 'email:rfc', 'required|email|max:191']);
test('ordinary addresses stay valid', function (string $email) {
expect(Validator::make(['email' => $email], ['email' => 'email'])->passes())->toBeTrue();
})->with([
'kunde@example.com',
'vor.name+shop@sub.example.de',
'müller@example.de',
]);

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