Compare commits

...

4 commits

Author SHA1 Message Date
6d85d2982a feat: API-Zugang endet mit der Buchung, optionales Ablaufdatum für API-Tokens
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Bisher blieb ein einmal erstellter Token gültig, auch wenn Abo bzw. Buchung
längst beendet waren. EnsureApiAccessIsBooked prüft jetzt bei jeder Anfrage
(v1 und /service/…-Altpfade), ob das Konto API-Zugang hat
(ApiAccessEligibilityService::hasApiAccess), sonst 403. Die Tokens bleiben
erhalten und funktionieren nach erneuter Buchung wieder.

Kunden können beim Erstellen optional ein Ablaufdatum setzen (bis 5 Jahre);
die Token-Liste zeigt „Gültig bis“ und einen Hinweis, wenn der Zugang
pausiert ist. sanctum:prune-expired räumt abgelaufene Tokens nach 30 Tagen
täglich ab. API-Doku (public/api/v1.yml) ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:41:24 +00:00
7fa310fa76 security: Proxy-Vertrauen nur noch lokal, HSTS in Produktion, Tests ohne echte ntfy-Pushes
Live-Prüfung 08.10.2026: nginx beantwortet die Anfragen direkt (kein CDN,
kein vorgeschalteter Proxy), die App vertraute aber jedem Proxy
(trustProxies at '*'). Jeder Client konnte per X-Forwarded-For seine IP
vorgeben und damit IP-Limits bei Login und Passwort-Reset umgehen sowie
IP-Protokolle fälschen. Jetzt steuert config/trustedproxy.php das Vertrauen:
lokal (Traefik) weiterhin '*', sonst keinem Proxy; ein späteres CDN kommt
über TRUSTED_PROXIES dazu.

HSTS fehlte auf Live. SecurityHeaders setzt Strict-Transport-Security auf
HTTPS-Antworten, standardmäßig nur in Produktion (max-age 1 Jahr, bewusst
ohne includeSubDomains/preload).

phpunit.xml leert MONITORING_NTFY_*, damit Testläufe keine echten
Push-Nachrichten mehr auslösen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:41:24 +00:00
6eb7c08863 dev: Deploy v1.4.0 und v1.4.1 im Git-Deploy-Protokoll, Release-Stand im Sicherheitsaudit und Entwicklungsplan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:19:19 +00:00
c76e420f1d fix: Abgleich der Firmenrechte entzieht Bestandskonten ohne E-Mail-Bestätigung nicht mehr pauschal alle Rechte — memberships:resync-auto mit Probelauf und Notbremse
Auf Live meldete memberships:resync-auto nach v1.4.0 „vorher 19607, nachher 9":
syncMembershipsForEmail() entzog Konten ohne email_verified_at alle
automatischen Rechte, und 19.597 davon gehören Bestandskonten aus dem
Altsystem. Jetzt wird nur entzogen, was keine E-Mail-Grundlage mehr hat —
unabhängig vom Kontostatus (S-03 bleibt wirksam); neue Rechte weiterhin nur
für aktive, verifizierte Konten (F01).

memberships:resync-auto läuft in einer Transaktion, kennt --dry-run und rollt
zurück, wenn mehr als 5 % (mindestens 50) der Rechte wegfallen würden
(--force zum Erzwingen). Probelauf gegen die Live-Kopie: 19.607 → 19.316.
Wiederherstellung: storage/app/private/restore-company-user-auto-2026-10-07.sql
(nicht versioniert, INSERT IGNORE, idempotent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:22:25 +00:00
37 changed files with 581 additions and 96 deletions

View file

@ -346,3 +346,10 @@ IMAGE_PRECHECK=false
IMAGE_PRECHECK_STAGE=1
# IMAGE_PRECHECK_MODEL=
# IMAGE_PRECHECK_DOWNGRADE=
# Proxy-Vertrauen: leer = keinem Proxy (Live, nginx direkt). Lokal ohne Angabe `*` (Traefik).
# Steht ein Proxy/CDN davor, dessen Adressbereiche kommagetrennt eintragen.
# TRUSTED_PROXIES=
# HSTS: ohne Angabe nur bei APP_ENV=production an, max-age 1 Jahr.
# SECURITY_HSTS=
# SECURITY_HSTS_MAX_AGE=31536000

View file

@ -15,11 +15,23 @@ use Illuminate\Support\Facades\DB;
* Massen-Updates per Query-Builder.
*
* Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt.
*
* Sicherung (nach dem Abgleich vom 07.10.2026, v1.4.1): Der Lauf geschieht in
* einer Transaktion. `--dry-run` rollt immer zurück und zeigt nur die Zahlen.
* Würde mehr als MAX_REVOKE_SHARE der Rechte (mindestens MAX_REVOKE_ABSOLUTE)
* wegfallen, rollt das Kommando zurück, solange nicht `--force` gesetzt ist —
* ein solcher Einbruch deutet auf einen Fehler, nicht auf veraltete Rechte.
*/
class ResyncAutomaticMemberships extends Command
{
private const MAX_REVOKE_SHARE = 0.05;
private const MAX_REVOKE_ABSOLUTE = 50;
protected $signature = 'memberships:resync-auto
{--user= : Nur diesen User (ID) abgleichen}';
{--user= : Nur diesen User (ID) abgleichen}
{--dry-run : Nur zählen, nichts speichern}
{--force : Auch bei auffällig vielen Entzügen speichern}';
protected $description = 'Gleicht automatisch vergebene Firmenrechte mit den aktuellen Kontakt- und Firmenadressen ab.';
@ -39,23 +51,50 @@ class ResyncAutomaticMemberships extends Command
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($userIds->count());
DB::beginTransaction();
foreach ($userIds->chunk(200) as $chunk) {
User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void {
$contactAccess->syncMembershipsForEmail($user);
$bar->advance();
});
try {
$bar = $this->output->createProgressBar($userIds->count());
foreach ($userIds->chunk(200) as $chunk) {
User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void {
$contactAccess->syncMembershipsForEmail($user);
$bar->advance();
});
}
$bar->finish();
$this->newLine();
$after = $this->countAutomaticGrants();
$revoked = max(0, $before - $after);
$summary = "Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}.";
if ($this->option('dry-run')) {
DB::rollBack();
$this->info($summary.' Probelauf — nichts gespeichert.');
return self::SUCCESS;
}
$limit = max(self::MAX_REVOKE_ABSOLUTE, (int) ceil($before * self::MAX_REVOKE_SHARE));
if ($revoked > $limit && ! $this->option('force')) {
DB::rollBack();
$this->error($summary." Es würden {$revoked} Rechte wegfallen (Grenze {$limit}) — zurückgerollt. Ursache prüfen, nur bei Absicht mit --force wiederholen.");
return self::FAILURE;
}
DB::commit();
$this->info($summary);
return self::SUCCESS;
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
$bar->finish();
$this->newLine();
$after = $this->countAutomaticGrants();
$this->info("Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}.");
return self::SUCCESS;
}
private function countAutomaticGrants(): int

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Api\ApiAccessEligibilityService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API-Zugang nur mit laufender Buchung (08.10.2026): aktives Abo,
* aktive Zahlungsoption oder Bestandsschutz — dieselbe Regel wie beim
* Erzeugen eines Tokens (ApiAccessEligibilityService), jetzt bei jedem
* Aufruf. Endet die Buchung, sperren die Tokens; sie bleiben erhalten und
* funktionieren wieder, sobald erneut gebucht ist. Gilt für API v1 und die
* Legacy-Aliasse unter /service.
*/
class EnsureApiAccessIsBooked
{
public function __construct(private readonly ApiAccessEligibilityService $eligibility) {}
/**
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user instanceof User && ! $this->eligibility->hasApiAccess($user)) {
return response()->json([
'message' => 'API access requires an active subscription or booking.',
], 403);
}
return $next($request);
}
}

View file

@ -10,6 +10,7 @@ use Symfony\Component\HttpFoundation\Response;
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* HSTS nur auf HTTPS-Antworten und laut config/security.php.
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene
* Inline-Script-Strategie — separat einführen, nicht nebenbei.
*/
@ -30,6 +31,10 @@ class SecurityHeaders
}
}
if ($request->isSecure() && config('security.hsts.enabled') && ! $response->headers->has('Strict-Transport-Security')) {
$response->headers->set('Strict-Transport-Security', 'max-age='.(int) config('security.hsts.max_age'));
}
return $response;
}
}

View file

@ -5,14 +5,24 @@ namespace App\Services\Api;
use App\Models\User;
/**
* Token-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
* API-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
* aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption
* (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat —
* spiegelbildlich zu User::hasActiveBooking().
* spiegelbildlich zu User::hasActiveBooking(). Geprüft beim Erzeugen eines
* Tokens und bei jedem API-Aufruf.
*/
class ApiAccessEligibilityService
{
public function canCreateToken(User $user): bool
{
return $this->hasApiAccess($user);
}
/**
* Gilt beim Erzeugen eines Tokens und bei jedem API-Aufruf
* (EnsureApiAccessIsBooked).
*/
public function hasApiAccess(User $user): bool
{
return $this->denialReason($user) === null;
}

View file

@ -129,12 +129,7 @@ class ContactAccessService
{
$email = mb_strtolower(trim((string) $user->email));
// Ohne nachgewiesene Adresse gibt es keine Grundlage für automatische
// Rechte — vorhandene fallen weg (Sicherheitsaudit 07.10.2026, S-03:
// der Abgleich läuft jetzt auch außerhalb des Logins).
if (! $user->is_active || ! $user->hasVerifiedEmail() || $email === '') {
$this->revokeStaleAutomaticGrants($user, collect());
if ($email === '') {
return;
}
@ -142,9 +137,15 @@ class ContactAccessService
$companies = $this->companiesFor($email);
$companyIds = $this->companyIdsFrom($contacts, $companies);
// Entzogen wird nur, was keine E-Mail-Grundlage mehr hat — unabhängig
// vom Kontostatus (S-03). Bestandskonten aus dem Altsystem haben kein
// `email_verified_at`; ihre noch begründeten Rechte bleiben. Eine
// erste Fassung entzog Unverifizierten pauschal alles und nahm beim
// Abgleich am 07.10.2026 so 19.598 begründete Rechte weg (v1.4.1).
$this->revokeStaleAutomaticGrants($user, $companyIds);
if ($companyIds->isEmpty()) {
// Neue Rechte nur für aktive Konten mit nachgewiesener Adresse (F01).
if (! $user->is_active || ! $user->hasVerifiedEmail() || $companyIds->isEmpty()) {
return;
}

View file

@ -118,16 +118,18 @@ return Application::configure(basePath: dirname(__DIR__))
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
// von config('auth.basic.*') (greift auch unter `config:cache`).
$middleware->append(BasicAuthMiddleware::class);
// Trust all proxies (for Traefik/Docker setup)
// Welchen Proxies vertraut wird, steht in config/trustedproxy.php:
// lokal hinter Traefik allen, auf Live (nginx direkt, kein Proxy)
// keinem — sonst könnte jeder Client per X-Forwarded-For seine IP
// fälschen und damit IP-Limits und Protokolle umgehen.
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
// Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird,
// Host-Whitelist: Wo Proxies inkl. X-Forwarded-Host vertraut wird,
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten

23
config/security.php Normal file
View file

@ -0,0 +1,23 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| HTTP Strict Transport Security
|--------------------------------------------------------------------------
|
| Weist Browser an, die Domain für `max_age` Sekunden nur noch per HTTPS
| aufzurufen. Wird nur auf HTTPS-Antworten gesetzt und standardmäßig nur
| in Produktion. Bewusst ohne includeSubDomains und preload: beides
| lässt sich kaum zurücknehmen und betrifft auch fremd betriebene
| Subdomains.
|
*/
'hsts' => [
'enabled' => (bool) env('SECURITY_HSTS', env('APP_ENV') === 'production'),
'max_age' => (int) env('SECURITY_HSTS_MAX_AGE', 31536000),
],
];

22
config/trustedproxy.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Vertrauenswürdige Proxies
|--------------------------------------------------------------------------
|
| Nur Anfragen von diesen Adressen dürfen per X-Forwarded-* die Client-IP,
| das Protokoll und den Host vorgeben. Lokal läuft die App hinter Traefik
| (`*` = der aufrufende Container). Auf Live beantwortet nginx die
| Anfragen direkt — dort bleibt die Liste leer, sonst könnte jeder Client
| seine IP fälschen und IP-Limits beim Login oder Passwort-Reset umgehen.
| Kommt einmal ein Proxy oder CDN davor: dessen Adressbereiche als
| kommagetrennte Liste in TRUSTED_PROXIES eintragen.
|
*/
'proxies' => env('TRUSTED_PROXIES', env('APP_ENV') === 'local' ? '*' : null) ?: null,
];

View file

@ -260,3 +260,5 @@ Ausfall des Anbieters keinen Deploy abbricht.
| 06.10.2026 | FPM und Nginx-Fehlerlog | `sudo -S service php8.4-fpm reload` ohne Passwort bestätigt (Master bleibt, Worker neu) — `deploy.sh` lädt FPM künftig selbst, `PK_FPM_RELOAD` bleibt ungesetzt. Nginx-Fehlerlog ausgewertet: Verbindungsabbrüche nur zu den Deploy-Zeitpunkten, `process_control_timeout` bewusst nicht gesetzt (siehe „Betrieb: PHP-FPM beim Deploy“). |
| 06.10.2026 | Worker-Ausfall nach `v1.3.0` | Supervisor lief nicht. `queue:restart` beim Deploy von `v1.3.0` (16:51) hat den Worker beendet, niemand startete ihn neu — KI-Prüfungen, Mails und eine Zahlungsbestätigung lagen über eine Stunde. Aufgefallen über die Systemstatus-Kachel. Supervisor in Ploi neu gestartet, Rückstau mit einem Ersatz-Worker (`nohup`) abgearbeitet. Ursache für den gestoppten Supervisor ohne root-Zugang nicht feststellbar. Folgen: Worker-Prüfung am Ende von `deploy.sh` (`v1.3.2`), externer Monitor auf `/health` (status.adametz.media). |
| 06.10.2026 | `v1.3.2` | Worker-Prüfung im Deploy. Von Hand nachgeholt (Skript lief aus der Kopie von `v1.3.1`): `monitoring:queue-probe` → Test-Job nach 4 s abgeholt. `systemctl is-enabled supervisor` → `enabled`; ein Neustart ohne Autostart scheidet als Ursache des Ausfalls damit weitgehend aus. |
| 07.10.2026 | `v1.4.0` | Sicherheitsaudit S-01–S-06, Passkeys, Zweitfaktor-Pflicht für Admins; Migration `create_passkeys_table` (`--migrations`). Vorher brach `deploy.sh` wegen Abweichungen ab: Die Doku-Commits nach `v1.3.2` waren von Hand hochgeladen worden, `deploy.sh` hatte dabei sein Ausführungsrecht verloren. Mit `git checkout -- dev/betrieb/…` bereinigt. **Danach entzog `memberships:resync-auto` 19.598 von 19.607 automatischen Firmenrechten**: Bestandskonten ohne `email_verified_at` verloren pauschal alles. |
| 07.10.2026 | `v1.4.1` | Hotfix für den Abgleich: Entzogen wird nur noch, was keine Grundlage mehr hat; das Kommando läuft in einer Transaktion, mit `--dry-run` und Notbremse ab 5 %. Rechte aus dem Backup vor dem Deploy wiederhergestellt (`INSERT IGNORE`, 19.598 eingefügt, danach 19.607). Hinweis: `mysql` auf dem Server braucht `-h`/`-P` aus der `.env`, sonst „Access denied“ für `…@'localhost'`; ohne Passwort geht es über `php artisan tinker`. Probelauf 19.607 → 19.316, danach ausgeführt. Wiederholung am 08.10. ohne Änderung (19.316 Rechte, 14.911 User). Die Nacht verlief ohne Auffälligkeiten. |

View file

@ -2,7 +2,7 @@
**Grundlage:** [SICHERHEITSAUDIT-2026-10-07.md](SICHERHEITSAUDIT-2026-10-07.md), Repository-Stand `2215826`
**Stand:** 07.10.2026 – Phasen 1–3 und Entscheidung 5 (Passkeys, Pflicht-Zweitfaktor für Admins) umgesetzt, noch nicht committet/deployt; übrige Phase 4 offen
**Stand:** 08.10.2026 – Phasen 1–3 und Entscheidung 5 (Passkeys, Pflicht-Zweitfaktor für Admins) live seit `v1.4.0` (07.10.2026), Hotfix `v1.4.1`; Abgleich der Firmenrechte ausgeführt (19.607 → 19.316, Wiederholung stabil). Übrige Phase 4 offen
**Ausführung:** Befehle direkt im Dev-Container (`php artisan …`, ohne Sail – siehe `.ai/rules/general.md`)
@ -78,7 +78,7 @@ Alles zusammen geht als ein Release raus. Vorgeschlagen ist `v1.4.0`: neue Funkt
*Nach dem Deploy:*
8. `php artisan memberships:resync-auto`. Die Ausgabe nennt die Zahl der automatischen Rechte vorher und nachher; sie mit Schritt 6 vergleichen.
8. Erst `php artisan memberships:resync-auto --dry-run`, danach ohne Option. Zu erwarten sind laut Probelauf mit der Live-Kopie rund 291 von 19.607 Rechten. Seit v1.4.1 rollt das Kommando zurück, wenn mehr als 5 % (mindestens 50) wegfallen würden; dann gilt: nicht mit `--force` erzwingen, sondern die Ursache klären.
9. Die üblichen Prüfungen aus Abschnitt D, Punkt 17.
10. **Smoke-Test der Neuerungen:**
- Als Admin anmelden. Es erscheint „Anmeldung bestätigen“; dort einen Passkey bzw. die App einrichten oder bestätigen.
@ -88,6 +88,17 @@ Alles zusammen geht als ein Release raus. Vorgeschlagen ist `v1.4.0`: neue Funkt
*Rollback (Abschnitt E):* `bash dev/betrieb/deploy.sh v1.3.2`. Die Tabelle `passkeys` bleibt dabei stehen; der alte Code nutzt sie nicht. Ein Rollback entfernt auch die Admin-Pflicht. Soll nur die Pflicht weg, reicht der Notfallschalter `AUTH_REQUIRE_STRONG_ADMIN=false` mit anschließendem `php artisan config:cache`.
**Vorfall 07.10.2026: Abgleich mit v1.4.0 entzieht 19.598 begründete Firmenrechte (behoben in v1.4.1).**
- **Was passiert ist:** `memberships:resync-auto` meldete auf Live „vorher 19607, nachher 9“.
- **Ursache:** `syncMembershipsForEmail()` entzog in v1.4.0 Konten ohne `email_verified_at` pauschal alle automatischen Rechte. 19.597 der 19.607 Rechte gehören solchen Bestandskonten aus dem Altsystem. Die Tests deckten nur verifizierte Konten ab.
- **Fix v1.4.1:**
- Entzogen wird nur, was keine E-Mail-Grundlage mehr hat, unabhängig vom Kontostatus. Neue Rechte gibt es weiterhin nur für aktive, verifizierte Konten.
- Das Kommando läuft in einer Transaktion, hat `--dry-run` und rollt bei auffällig vielen Entzügen zurück.
- Regressionstests sind ergänzt.
- Probelauf gegen die Live-Kopie: 19.607 → 19.316.
- **Wiederherstellung:** `storage/app/private/restore-company-user-auto-2026-10-07.sql`, erzeugt aus dem Live-Backup vor dem Deploy. Sie spielt nur `INSERT IGNORE` der 19.607 automatischen Rechte ein und darf mehrfach laufen. Lokal in einer zurückgerollten Transaktion geprüft: 19.607 wiederhergestellt, zweiter Lauf 0.
**Vorfall 07.10.2026: lokale Datenbank geleert (nicht durch diese Umsetzung).**
- **Was das Binärlog zeigt:** Um 09:44 UTC (`binlog.000009`, Pos. 566161486) wurden alle Tabellen von `pressekonto` auf `global-mysql` gelöscht, durch ein `migrate:fresh` (`RefreshDatabase`) eines frischen Demo-Projekts. In diesem Projekt wurden die Vorlagen aus `dev/git-deploy-monitoring` getestet (Commit `2215826`).

View file

@ -271,6 +271,6 @@ Empfehlung: die betroffenen Abhängigkeitsketten gezielt aktualisieren, danach B
## 8. Nachtrag – Umsetzung vom 07.10.2026
S-01 bis S-06 sind im Code behoben und durch Regressionstests abgesichert. Die Entscheidungen, Details, Testdateien und Deploy-Schritte stehen in [ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md](ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md), Abschnitt 0.
S-01 bis S-06 sind im Code behoben und durch Regressionstests abgesichert. Live seit **`v1.4.0`** (07.10.2026), dazu der Hotfix **`v1.4.1`** für den Abgleich der Firmenrechte zu S-03. Die Entscheidungen, Details, Testdateien, Deploy-Schritte und beide Vorfälle stehen in [ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md](ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md), Abschnitt 0; das Deploy-Protokoll steht in `dev/betrieb/git-deploy.md`.
Nicht umgesetzt wurden die Härtungspunkte aus Abschnitt 4, darunter Pflicht-2FA für Admins, npm-Updates, Proxy, HSTS und CSP.
Ebenfalls in `v1.4.0` aus Abschnitt 4 umgesetzt: die Pflicht für einen zweiten Faktor bei Super-Admins und Admins (Passkey oder Authenticator-App). Offen sind npm-Updates, Proxy, HSTS, Passwortregeln, eine dauerhafte Widerrufsmarke, der Ablauf von API-Tokens, das Login-Limit pro IP und CSP.

View file

@ -41,6 +41,16 @@
<server name="SENTRY_LARAVEL_DSN" value="" force="true"/>
<server name="SENTRY_DSN" value="" force="true"/>
<server name="SENTRY_BROWSER_DSN" value="" force="true"/>
<!-- Betriebs-Alarme aus Tests nie als echte Push-Nachricht (ntfy) verschicken. -->
<env name="MONITORING_NTFY_URL" value="" force="true"/>
<env name="MONITORING_NTFY_TOPIC" value="" force="true"/>
<env name="MONITORING_NTFY_TOKEN" value="" force="true"/>
<server name="MONITORING_NTFY_URL" value="" force="true"/>
<server name="MONITORING_NTFY_TOPIC" value="" force="true"/>
<server name="MONITORING_NTFY_TOKEN" value="" force="true"/>
<!-- Proxy-Vertrauen und HSTS testen die Tests selbst; Standard wie auf Live. -->
<env name="TRUSTED_PROXIES" value="" force="true"/>
<env name="SECURITY_HSTS" value="false" force="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>

View file

@ -13,6 +13,11 @@ info:
Token trägt Berechtigungen (Abilities), die den Zugriff auf die
Endpunkte steuern. Tokens werden im Publisher-Konto unter „API-Tokens"
selbst erstellt und widerrufen (Voraussetzung: aktive Buchung).
Die Buchung wird bei jeder Anfrage geprüft: Endet sie, antwortet die
API mit 403 („API access requires an active subscription or booking."),
bis wieder gebucht ist — die Tokens selbst bleiben erhalten. Optional
lässt sich beim Erstellen ein Ablaufdatum setzen; danach antwortet die
API für diesen Token mit 401.
Rate-Limit: 60 Anfragen pro Minute je Token (Header X-RateLimit-Limit /
X-RateLimit-Remaining, bei 429 zusätzlich Retry-After).

View file

@ -3,6 +3,7 @@
use App\Actions\Admin\UserImpersonation;
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Services\Api\ApiAccessEligibilityService;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -14,6 +15,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public string $tokenName = '';
/**
* Optionales Ablaufdatum (08.10.2026): leer = gültig, solange gebucht
* ist. Gesetzt endet der Token am Ende dieses Tages.
*/
public ?string $tokenExpiresOn = null;
/** @var list<string> */
public array $selectedAbilities = ['press-releases:read'];
@ -49,6 +56,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
$validated = $this->validate([
'tokenName' => ['required', 'string', 'max:80'],
'tokenExpiresOn' => ['nullable', 'date_format:Y-m-d', 'after:today', 'before_or_equal:'.now()->addYears(5)->toDateString()],
'selectedAbilities' => ['required', 'array', 'min:1'],
'selectedAbilities.*' => ['required', 'string', Rule::in(array_keys(self::ABILITIES))],
]);
@ -60,12 +68,16 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
$token = auth()->user()->createToken(
$validated['tokenName'],
$validated['selectedAbilities'],
filled($validated['tokenExpiresOn'] ?? null)
? Carbon::createFromFormat('Y-m-d', $validated['tokenExpiresOn'])->endOfDay()
: null,
);
$this->plainTextToken = $token->plainTextToken;
$this->notification = __('Token wurde erstellt. Bitte kopieren Sie ihn jetzt, er wird später nicht erneut angezeigt.');
$this->eligibilityMessage = null;
$this->tokenName = '';
$this->tokenExpiresOn = null;
$this->selectedAbilities = ['press-releases:read'];
}
@ -92,6 +104,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
'abilityOptions' => self::ABILITIES,
'securityProofKind' => $this->securityProofKind(auth()->user()),
'canCreateApiToken' => $denialReason === null && ! $isImpersonating,
'hasApiAccess' => $denialReason === null,
'canRevokeApiTokens' => ! $isImpersonating,
'apiTokenDenialReason' => $isImpersonating
? __(self::IMPERSONATION_DENIAL_MESSAGE)
@ -99,7 +112,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
'tokens' => auth()->user()
->tokens()
->latest()
->get(['id', 'name', 'abilities', 'last_used_at', 'created_at']),
->get(['id', 'name', 'abilities', 'last_used_at', 'created_at', 'expires_at']),
];
}
@ -151,7 +164,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.lock-closed class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">{{ $eligibilityMessage ?? $apiTokenDenialReason }}</div>
<div class="flex-1">
{{ $eligibilityMessage ?? $apiTokenDenialReason }}
@if ($tokens->isNotEmpty() && ! $hasApiAccess)
<span class="block mt-1">{{ __('Ihre vorhandenen Tokens sind bis dahin gesperrt. Sie bleiben gespeichert und funktionieren wieder, sobald eine Buchung aktiv ist.') }}</span>
@endif
</div>
</div>
@endif
@ -189,6 +207,13 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
<flux:error name="tokenName" />
</flux:field>
<flux:field>
<flux:label>{{ __('Gültig bis (optional)') }}</flux:label>
<flux:input type="date" wire:model="tokenExpiresOn" min="{{ now()->addDay()->toDateString() }}" max="{{ now()->addYears(5)->toDateString() }}" class="max-w-[220px]" />
<flux:description>{{ __('Leer lassen: Der Token gilt, solange Ihre Buchung läuft. Mit Datum endet er an diesem Tag um 23:59 Uhr — in jedem Fall spätestens mit dem Ende Ihrer Buchung.') }}</flux:description>
<flux:error name="tokenExpiresOn" />
</flux:field>
<div>
<flux:label>{{ __('Berechtigungen') }}</flux:label>
<div class="mt-3 grid gap-3 md:grid-cols-2">
@ -228,6 +253,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
<flux:table.column>{{ __('Berechtigungen') }}</flux:table.column>
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
<flux:table.column>{{ __('Zuletzt genutzt') }}</flux:table.column>
<flux:table.column>{{ __('Gültig bis') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@ -253,6 +279,15 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
{{ $token->last_used_at?->format('d.m.Y H:i') ?? __('Nie') }}
</span>
</flux:table.cell>
<flux:table.cell>
@if ($token->expires_at?->isPast())
<span class="badge warn">{{ __('Abgelaufen am :date', ['date' => $token->expires_at->format('d.m.Y')]) }}</span>
@elseif ($token->expires_at)
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $token->expires_at->format('d.m.Y') }}</span>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Solange gebucht') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:button
size="sm"
@ -268,7 +303,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<flux:table.cell colspan="6">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">

View file

@ -6,12 +6,13 @@ use App\Http\Controllers\Api\V1\LegalAcceptanceController;
use App\Http\Controllers\Api\V1\NewsletterSubscriptionController;
use App\Http\Controllers\Api\V1\PressReleaseController;
use App\Http\Controllers\Api\V1\PressReleaseImageController;
use App\Http\Middleware\EnsureApiAccessIsBooked;
use App\Http\Middleware\EnsureApiTokenRateLimit;
use App\Http\Middleware\EnsureApiUserIsActive;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')
->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class])
->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiAccessIsBooked::class, EnsureApiTokenRateLimit::class])
// Controller erwarten int-IDs — nicht-numerische Werte (z. B. vergessene
// Platzhalter wie „PM_ID") sollen 404 statt TypeError/500 ergeben.
->where(['pressRelease' => '[0-9]+', 'pressReleaseImage' => '[0-9]+', 'company' => '[0-9]+'])

View file

@ -261,3 +261,12 @@ Schedule::command('queue:prune-failed', ['--hours=720'])
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('queue:prune-failed'));
// Abgelaufene API-Tokens (optionales Ablaufdatum, 08.10.2026) 30 Tage nach
// Ablauf entfernen (täglich 04:10). Bis dahin zeigt die Token-Seite sie als
// „abgelaufen“ an; Sanctum lehnt sie ab dem Ablauf ohnehin ab.
Schedule::command('sanctum:prune-expired', ['--hours=720'])
->dailyAt('04:10')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('sanctum:prune-expired'));

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Legacy\LegacyPressReleaseImageController;
use App\Http\Controllers\Api\V1\CompanyController;
use App\Http\Controllers\Api\V1\PressReleaseController;
use App\Http\Controllers\Api\V1\PressReleaseImageController;
use App\Http\Middleware\EnsureApiAccessIsBooked;
use App\Http\Middleware\EnsureApiTokenRateLimit;
use App\Http\Middleware\EnsureApiUserIsActive;
use Illuminate\Support\Facades\Route;
@ -31,7 +32,7 @@ use Illuminate\Support\Facades\Route;
Route::prefix('service')
->name('legacy-service.')
->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class])
->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiAccessIsBooked::class, EnsureApiTokenRateLimit::class])
// Nicht-numerische IDs fallen in den 410-Catch-all statt auf einen
// TypeError im Controller zu laufen.
->where(['pressRelease' => '[0-9]+', 'company' => '[0-9]+'])

View file

@ -11,7 +11,6 @@ use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
@ -33,7 +32,7 @@ function assignmentApiUser(Portal $portal = Portal::Businessportal24, bool $allo
// freigeschalteter Einlieferer (connektar).
config()->set('newsroom.api_assignment.allowed_user_ids', $allowlisted ? [$user->id] : []);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
apiActingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
return $user;
}

View file

@ -7,7 +7,6 @@ use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
@ -28,7 +27,7 @@ function portalResolutionApiUser(Portal $portal = Portal::Businessportal24): Use
]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
Sanctum::actingAs($user, [
apiActingAs($user, [
'press-releases:read',
'press-releases:write',
'companies:read',
@ -150,7 +149,7 @@ test('v1 press release creation resolves the portal from parameter and domain',
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
config()->set('app.theme', 'presseecho');
@ -179,7 +178,7 @@ test('v1 press release creation rejects the portal value both', function () {
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
$this->postJson('/api/v1/press-releases', [
'portal' => 'both',
@ -204,7 +203,7 @@ test('v1 update keeps the portal unless an explicit portal parameter is sent', f
->for(Category::factory()->withTranslations()->create(['portal' => Portal::Both]))
->create(['user_id' => $user->id, 'company_id' => $company->id]);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
// Update über die "falsche" Domain verschiebt die PM nicht …
config()->set('app.theme', 'presseecho');

View file

@ -15,7 +15,6 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
@ -37,7 +36,7 @@ function parityApiUser(bool $immediatePublication = true): User
$user->givePermissionTo('api:immediate-publication');
}
Sanctum::actingAs($user, [
apiActingAs($user, [
'press-releases:read',
'press-releases:write',
'press-release-images:write',

View file

@ -5,7 +5,6 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\NewsletterSubscription;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('legacy api keys in query parameters are not accepted but get the migration hint', function () {
@ -24,7 +23,7 @@ test('api user can read only own companies', function () {
$otherCompany = Company::factory()->businessportal24()->create(['name' => 'Fremde Firma']);
$user->companies()->attach($ownCompany->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['companies:read']);
apiActingAs($user, ['companies:read']);
$this->getJson('/api/v1/companies')
->assertOk()
@ -40,7 +39,7 @@ test('api user can list categories with press release read ability', function ()
$user = User::factory()->create();
Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
Sanctum::actingAs($user, ['press-releases:read']);
apiActingAs($user, ['press-releases:read']);
$this->getJson('/api/v1/categories')
->assertOk()
@ -56,7 +55,7 @@ test('api user can subscribe an email to newsletter', function () {
/** @var TestCase $this */
$user = User::factory()->create();
Sanctum::actingAs($user, ['newsletter:subscribe']);
apiActingAs($user, ['newsletter:subscribe']);
$this->postJson('/api/v1/newsletter/subscribe', [
'portal' => 'presseecho',
@ -86,7 +85,7 @@ test('resubscribing keeps an existing confirmation intact', function () {
'subscribed_at' => now()->subMonth(),
'unsubscribed_at' => now()->subDay(),
]);
Sanctum::actingAs($user, ['newsletter:subscribe']);
apiActingAs($user, ['newsletter:subscribe']);
$this->postJson('/api/v1/newsletter/subscribe', [
'portal' => 'presseecho',
@ -116,7 +115,7 @@ test('an orphaned subscription without owner can be rebound instead of being per
]);
$user = User::factory()->create();
Sanctum::actingAs($user, ['newsletter:subscribe']);
apiActingAs($user, ['newsletter:subscribe']);
$this->postJson('/api/v1/newsletter/subscribe', [
'portal' => 'presseecho',
@ -140,7 +139,7 @@ test('an api user cannot take over another newsletter subscription', function ()
'is_confirmed' => true,
'subscribed_at' => now(),
]);
Sanctum::actingAs($attacker, ['newsletter:subscribe']);
apiActingAs($attacker, ['newsletter:subscribe']);
$this->postJson('/api/v1/newsletter/subscribe', [
'portal' => 'presseecho',

View file

@ -5,7 +5,6 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('api user can create list update and delete own press releases', function () {
@ -15,7 +14,7 @@ test('api user can create list update and delete own press releases', function (
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
$createResponse = $this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
@ -62,7 +61,7 @@ test('api submitted html text is sanitized before persistence', function () {
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
@ -88,7 +87,7 @@ test('api write ability is required to create press releases', function () {
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:read']);
apiActingAs($user, ['press-releases:read']);
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
@ -105,7 +104,7 @@ test('api user cannot access another users press release', function () {
$otherUser = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $owner->id]);
Sanctum::actingAs($otherUser, ['press-releases:read']);
apiActingAs($otherUser, ['press-releases:read']);
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
->assertForbidden();
@ -116,7 +115,7 @@ test('a soft deleted press release disappears from the api (Audit F04)', functio
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Bald geloescht']);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
$this->deleteJson("/api/v1/press-releases/{$pressRelease->id}")->assertNoContent();
@ -135,7 +134,7 @@ test('a soft deleted company is neither listed nor usable for new press releases
$user->companies()->attach($company->id, ['role' => 'owner']);
$company->delete();
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
apiActingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
$this->getJson('/api/v1/companies')->assertOk()->assertJsonMissing(['name' => 'Geloeschte Firma']);
$this->getJson("/api/v1/companies/{$company->id}")->assertForbidden();
@ -154,7 +153,7 @@ test('api list parameters are validated instead of passed through (Audit F11)',
$user = User::factory()->create();
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
Sanctum::actingAs($user, ['press-releases:read', 'companies:read']);
apiActingAs($user, ['press-releases:read', 'companies:read']);
$this->getJson('/api/v1/press-releases?'.http_build_query($query))->assertUnprocessable();
$this->getJson('/api/v1/companies?'.http_build_query(array_intersect_key($query, ['per_page' => true])) ?: '/api/v1/companies?per_page=-1')
@ -173,7 +172,7 @@ test('api list accepts the documented parameter range and always limits the quer
$user = User::factory()->create();
PressRelease::factory()->count(3)->create(['user_id' => $user->id]);
Sanctum::actingAs($user, ['press-releases:read']);
apiActingAs($user, ['press-releases:read']);
$this->getJson('/api/v1/press-releases?per_page=1')->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('meta.per_page', 1);
$this->getJson('/api/v1/press-releases?per_page=100&status=draft')->assertOk()->assertJsonCount(3, 'data');
@ -188,7 +187,7 @@ test('api press release input rejects unsupported languages and inactive or fore
$inactive = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => false]);
$foreign = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
$payload = fn (array $overrides): array => [
'company_id' => $company->id,
@ -214,7 +213,7 @@ test('api portal change re-validates the existing category against the new porta
$businessOnly = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
$shared = Category::factory()->withTranslations()->create(['portal' => Portal::Both, 'is_active' => true]);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']);
apiActingAs($user, ['press-releases:read', 'press-releases:write']);
$id = $this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,

View file

@ -6,7 +6,6 @@ use App\Models\PressReleaseImage;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('api user can upload list and delete own press release images', function () {
@ -19,7 +18,7 @@ test('api user can upload list and delete own press release images', function ()
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']);
apiActingAs($user, ['press-releases:read', 'press-release-images:write']);
$uploadResponse = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('press-release.jpg', 1200, 800)->size(5120),
@ -89,7 +88,7 @@ test('image upload is limited to eight megabytes', function () {
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-release-images:write']);
apiActingAs($user, ['press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('too-large.jpg')->size(8193),
@ -108,7 +107,7 @@ test('api user cannot manage another users press release images', function () {
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($otherUser, ['press-releases:read', 'press-release-images:write']);
apiActingAs($otherUser, ['press-releases:read', 'press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('foreign.jpg'),
@ -125,7 +124,7 @@ test('published press release images cannot be changed via api', function () {
'user_id' => $user->id,
]);
Sanctum::actingAs($user, ['press-release-images:write']);
apiActingAs($user, ['press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('published.jpg'),
@ -144,7 +143,7 @@ test('api image upload requires the per-image ai declaration', function () {
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-release-images:write']);
apiActingAs($user, ['press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('undeklariert.jpg'),

View file

@ -36,7 +36,7 @@ function rightsV2ApiDraft(): array
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']);
apiActingAs($user, ['press-releases:read', 'press-release-images:write']);
return compact('user', 'pressRelease');
}
@ -81,7 +81,7 @@ test('a valid upload stores the confirmation with the token and returns the new
/** @var TestCase $this */
// Echter Bearer-Token statt Sanctum::actingAs, damit die Token-ID im
// Protokoll landet (TransientToken hat keinen Datensatz).
$user = User::factory()->create();
$user = grantApiAccess(User::factory()->create());
$pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]);
$plainToken = $user->createToken('connektar', ['press-releases:read', 'press-release-images:write'])->plainTextToken;

View file

@ -4,6 +4,7 @@ use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\LegacyInvoice;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
@ -19,7 +20,7 @@ test('api create always produces a draft and ignores any status input', function
$category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]);
$user->companies()->attach($company->id, ['role' => 'owner']);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson('/api/v1/press-releases', [
'company_id' => $company->id,
@ -45,7 +46,7 @@ test('api submit route raises a draft to review without consuming quota and writ
'text' => 'Vollkommen unauffälliger Inhalt.',
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertOk()
@ -63,7 +64,21 @@ test('api submit responds 402 when the booking gate is enforced', function () {
/** @var TestCase $this */
config()->set('billing.enforce_booking', true);
// API-Zugang ohne Buchung: Altkunde mit bezahlter letzter Legacy-Rechnung
// (ApiAccessEligibilityService), aber ohne Abo/Zahlungsoption.
$user = User::factory()->create();
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 8101,
'user_id' => $user->id,
'legacy_user_id' => 8101,
'number' => 'PE-8101',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subYear(),
'imported_at' => now(),
]);
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
@ -98,6 +113,8 @@ test('api submit responds 422 when the monthly quota is exhausted', function ()
'text' => 'Inhalt',
]);
// Das Abo gewährt den API-Zugang; eine zusätzliche Zahlungsoption
// (apiActingAs) würde das Kontingent verfälschen.
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
@ -118,7 +135,7 @@ test('api submit auto-rejects a press release containing a banned word', functio
'text' => 'Inhalt',
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(422);
@ -134,7 +151,7 @@ test('api submit requires the write ability', function () {
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-releases:read']);
apiActingAs($user, ['press-releases:read']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertForbidden();
@ -147,7 +164,7 @@ test('api submit rejects a press release already in review', function () {
'user_id' => $user->id,
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(409);
@ -162,7 +179,7 @@ test('api user cannot submit another users press release', function () {
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($otherUser, ['press-releases:write']);
apiActingAs($otherUser, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertForbidden();
@ -178,7 +195,7 @@ test('api submit requires the ai text declaration (Art. 50 KI-VO)', function ()
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
->assertUnprocessable()
@ -203,7 +220,7 @@ test('api submit records the declared ai grade as binding confirmation', functio
'text' => 'Vollkommen unauffälliger Inhalt.',
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'assisted'])
->assertOk()
@ -220,7 +237,7 @@ test('api routes respond 404 instead of 500 for non-numeric press release ids',
/** @var TestCase $this */
$user = User::factory()->create();
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
// Vergessener Postman-Platzhalter wie „PM_ID" darf nicht als String in
// die int-typisierten Controller-Parameter laufen (TypeError/500).

View file

@ -1,12 +1,14 @@
<?php
use App\Enums\UserPaymentOptionStatus;
use App\Models\ApiUsageLog;
use App\Models\User;
use App\Models\UserPaymentOption;
use Tests\TestCase;
test('api requests are logged without storing bearer token contents', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$user = grantApiAccess(User::factory()->create(['is_active' => true]));
$plainTextToken = $user->createToken('API Client', ['press-releases:read'])->plainTextToken;
$tokenId = $user->tokens()->value('id');
@ -55,7 +57,7 @@ test('inactive users cannot use existing api tokens', function () {
test('api rate limit is scoped to each personal access token', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$user = grantApiAccess(User::factory()->create(['is_active' => true]));
$firstToken = $user->createToken('Busy Client', ['press-releases:read'])->plainTextToken;
$secondToken = $user->createToken('Other Client', ['press-releases:read'])->plainTextToken;
@ -78,3 +80,55 @@ test('api rate limit is scoped to each personal access token', function () {
->getJson('/api/v1/categories')
->assertOk();
});
/*
* 08.10.2026: API-Zugang nur mit laufender Buchung — bei jedem Aufruf, nicht
* nur beim Erzeugen des Tokens. Tokens bleiben erhalten und funktionieren
* wieder, sobald erneut gebucht ist.
*/
test('tokens stop working once the booking has ended and work again after rebooking', function (string $path) {
/** @var TestCase $this */
$user = grantApiAccess(User::factory()->create(['is_active' => true]));
$plainTextToken = $user->createToken('Integration', ['press-releases:read'])->plainTextToken;
$this->withToken($plainTextToken)->getJson($path)->assertSuccessful();
UserPaymentOption::query()->where('user_id', $user->id)->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'current_period_end' => now()->subDay(),
]);
$this->withToken($plainTextToken)->getJson($path)
->assertForbidden()
->assertJsonPath('message', 'API access requires an active subscription or booking.');
grantApiAccess($user);
$this->withToken($plainTextToken)->getJson($path)->assertSuccessful();
expect($user->tokens()->count())->toBe(1);
})->with([
'api v1' => '/api/v1/categories',
'legacy service alias' => '/service/pressreleases',
]);
test('tokens of accounts without any booking are rejected', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$plainTextToken = $user->createToken('Ohne Buchung', ['press-releases:read'])->plainTextToken;
$this->withToken($plainTextToken)->getJson('/api/v1/categories')->assertForbidden();
});
test('expired tokens are rejected', function () {
/** @var TestCase $this */
$user = grantApiAccess(User::factory()->create(['is_active' => true]));
$plainTextToken = $user->createToken('Befristet', ['press-releases:read'], now()->addDay())->plainTextToken;
$this->withToken($plainTextToken)->getJson('/api/v1/categories')->assertSuccessful();
$this->travel(2)->days();
// Der Test-Container behält den einmal aufgelösten Nutzer über Anfragen hinweg.
app('auth')->forgetGuards();
$this->withToken($plainTextToken)->getJson('/api/v1/categories')->assertUnauthorized();
});

View file

@ -140,3 +140,64 @@ test('the resync command removes stale automatic grants left by bulk updates', f
expect(membershipOf($user, $company))->toBeNull()
->and(membershipOf($keeper, $keptCompany)?->granted_via)->toBe('auto');
});
/*
* Regression v1.4.1: Bestandskonten aus dem Altsystem haben kein
* `email_verified_at`. Der Abgleich am 07.10.2026 entzog ihnen pauschal alle
* automatischen Rechte (19.598 von 19.607), obwohl die Grundlage bestand.
*/
test('legacy accounts without verified email keep automatic access whose basis still exists', function () {
/** @var TestCase $this */
$legacy = User::factory()->unverified()->create(['email' => 'altkunde@firma.test', 'is_active' => true]);
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create(['email' => 'altkunde@firma.test', 'portal' => $company->portal->value]);
$legacy->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'auto']);
$this->artisan('memberships:resync-auto')->assertSuccessful();
app(ContactAccessService::class)->syncMembershipsForEmail($legacy);
expect(membershipOf($legacy, $company)?->granted_via)->toBe('auto');
// Fällt die Grundlage weg, endet das Recht auch für sie (S-03).
$contact->delete();
expect(membershipOf($legacy, $company))->toBeNull();
});
test('inactive or unverified accounts do not receive new automatic access', function (array $attributes) {
$user = User::factory()->create(['email' => 'neu-ohne-nachweis@firma.test', ...$attributes]);
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create(['email' => 'neu-ohne-nachweis@firma.test', 'portal' => $company->portal->value]);
app(ContactAccessService::class)->syncMembershipsForEmail($user);
expect(membershipOf($user, $company))->toBeNull();
})->with([
'unverified' => [['email_verified_at' => null]],
'inactive' => [['is_active' => false]],
]);
test('the resync command rolls back a suspicious mass revocation unless forced', function () {
/** @var TestCase $this */
$company = Company::factory()->presseecho()->create();
$users = User::factory()->count(60)->create();
foreach ($users as $user) {
$user->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'auto']);
}
$count = fn (): int => DB::table('company_user')->where('granted_via', 'auto')->count();
$this->artisan('memberships:resync-auto', ['--dry-run' => true])
->expectsOutputToContain('vorher 60, nachher 0. Probelauf')
->assertSuccessful();
expect($count())->toBe(60);
$this->artisan('memberships:resync-auto')
->expectsOutputToContain('zurückgerollt')
->assertFailed();
expect($count())->toBe(60);
$this->artisan('memberships:resync-auto', ['--force' => true])->assertSuccessful();
expect($count())->toBe(0);
});

View file

@ -7,7 +7,6 @@ use App\Models\PressRelease;
use App\Models\User;
use App\Services\Company\CompanyNameNormalizer;
use Database\Seeders\RolesAndPermissionsSeeder;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
@ -45,7 +44,7 @@ test('the legacy api stores a clean name and records the correction', function (
config()->set('billing.enforce_booking', false);
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$response = $this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Steinreinigung - https://www.bgn.at/steinreinigung', 'email' => 'info@bgn.at'],

View file

@ -334,3 +334,66 @@ test('creating an api token requires a fresh identity proof (Sicherheitsaudit S-
expect($customer->tokens()->count())->toBe(0);
});
/*
* 08.10.2026: optionales Ablaufdatum für API-Tokens; ohne Datum gilt der
* Token, solange gebucht ist.
*/
test('a customer may give an api token an optional expiry date', function () {
/** @var TestCase $this */
$customer = grantApiAccess(User::factory()->create(['is_active' => true]));
$this->actingAs($customer);
Livewire::test('customer.tokens')
->set('tokenName', 'Befristet')
->set('selectedAbilities', ['press-releases:read'])
->set('tokenExpiresOn', now()->addDays(30)->toDateString())
->set('securityPassword', 'password')
->call('createToken')
->assertHasNoErrors()
->assertSee(now()->addDays(30)->format('d.m.Y'));
Livewire::test('customer.tokens')
->set('tokenName', 'Unbefristet')
->set('selectedAbilities', ['press-releases:read'])
->call('createToken')
->assertHasNoErrors()
->assertSee('Solange gebucht');
$limited = $customer->tokens()->where('name', 'Befristet')->firstOrFail();
$unlimited = $customer->tokens()->where('name', 'Unbefristet')->firstOrFail();
expect($limited->expires_at->toDateTimeString())->toBe(now()->addDays(30)->endOfDay()->toDateTimeString())
->and($unlimited->expires_at)->toBeNull();
});
test('an api token expiry date must lie in the future and within five years', function (string $date) {
/** @var TestCase $this */
$customer = grantApiAccess(User::factory()->create(['is_active' => true]));
$this->actingAs($customer);
Livewire::test('customer.tokens')
->set('tokenName', 'Ungültig')
->set('selectedAbilities', ['press-releases:read'])
->set('tokenExpiresOn', $date)
->set('securityPassword', 'password')
->call('createToken')
->assertHasErrors(['tokenExpiresOn']);
expect($customer->tokens()->count())->toBe(0);
})->with([
'today' => fn () => now()->toDateString(),
'past' => fn () => now()->subDay()->toDateString(),
'beyond five years' => fn () => now()->addYears(5)->addDay()->toDateString(),
'not a date' => 'morgen',
]);
test('the tokens page explains that existing tokens are paused without a booking', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$customer->createToken('Alte Integration', ['press-releases:read']);
$this->actingAs($customer);
Livewire::test('customer.tokens')
->assertSee('Ihre vorhandenen Tokens sind bis dahin gesperrt');
});

View file

@ -40,7 +40,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Livewire\Livewire;
use Mockery\MockInterface;
use Tests\Support\FailingDisk;
@ -476,7 +475,7 @@ test('the api refuses to remove an image once the status changed under the lock'
});
}));
Sanctum::actingAs($owner, ['press-release-images:write']);
apiActingAs($owner, ['press-release-images:write']);
$this->deleteJson('/api/v1/press-release-images/'.$image->id)->assertStatus(409);

View file

@ -24,7 +24,6 @@ use Illuminate\Http\Client\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Livewire\Livewire;
use Tests\TestCase;
@ -475,7 +474,7 @@ test('the api answers 422 with the reason on rejection and flags queued images',
/** @var TestCase $this */
$user = User::factory()->create();
$pr = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]);
Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']);
apiActingAs($user, ['press-releases:read', 'press-release-images:write']);
$payload = [
'copyright' => 'Beispiel AG',

View file

@ -13,7 +13,6 @@ use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Mail;
use Laravel\Sanctum\Sanctum;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Tests\TestCase;
@ -161,7 +160,7 @@ test('the api answers 428 with the required documents until they are accepted',
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']);
apiActingAs($owner, ['press-releases:write', 'press-releases:read']);
$this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(428)

View file

@ -6,7 +6,6 @@ use App\Rules\PlausibleEmailDomain;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
@ -57,7 +56,7 @@ test('the legacy api refuses a company address whose domain cannot exist', funct
Queue::fake();
test()->seed(RolesAndPermissionsSeeder::class);
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Hörwelt Basel', 'email' => 'info@hoerwelt-basel'],

View file

@ -10,7 +10,6 @@ use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
test('reclassifyIfClassified dispatches a non-routing job for an already classified press release', function () {
@ -84,7 +83,7 @@ test('api update of a classified press release re-classifies when the content ch
'classification' => PressReleaseClassification::Green->value,
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [
'text' => 'Komplett neuer Inhalt, der erneut geprüft werden muss.',
@ -112,7 +111,7 @@ test('api update does not re-classify when content is unchanged', function () {
'keywords' => 'alt',
]);
Sanctum::actingAs($user, ['press-releases:write']);
apiActingAs($user, ['press-releases:write']);
// Nur Keywords ändern – kein Titel/Text → keine Neuklassifikation.
$this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [

View file

@ -4,6 +4,7 @@ use App\Models\Company;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
@ -99,3 +100,51 @@ test('company create rejects svg logos', function () {
expect(Company::query()->where('name', 'SVG GmbH')->exists())->toBeFalse();
});
test('https responses carry hsts only when it is enabled', function () {
/** @var TestCase $this */
$this->get('https://presseecho.test/de')->assertHeaderMissing('Strict-Transport-Security');
config()->set('security.hsts', ['enabled' => true, 'max_age' => 31536000]);
$this->get('https://presseecho.test/de')->assertHeader('Strict-Transport-Security', 'max-age=31536000');
$this->get('http://presseecho.test/de')->assertHeaderMissing('Strict-Transport-Security');
});
test('forwarded client ips are ignored unless the proxy is trusted', function () {
/** @var TestCase $this */
Route::get('/_test/client-ip', fn () => request()->ip());
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
->withHeader('X-Forwarded-For', '198.51.100.7')
->get('/_test/client-ip')
->assertContent('203.0.113.10');
config()->set('trustedproxy.proxies', '203.0.113.10');
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
->withHeader('X-Forwarded-For', '198.51.100.7')
->get('/_test/client-ip')
->assertContent('198.51.100.7');
});
test('production trusts no proxy unless one is configured', function (string $appEnv, ?string $trustedProxies, ?string $expected) {
$code = 'require "vendor/autoload.php"; echo json_encode((require "config/trustedproxy.php")["proxies"]);';
$result = Process::path(base_path())
->env(array_filter(['APP_ENV' => $appEnv, 'TRUSTED_PROXIES' => $trustedProxies], fn ($value) => $value !== null) + ['TRUSTED_PROXIES' => false])
->run([PHP_BINARY, '-r', $code]);
expect(json_decode($result->throw()->output()))->toBe($expected);
})->with([
'live without setting' => ['production', null, null],
'local behind traefik' => ['local', null, '*'],
'live behind a cdn' => ['production', '10.0.0.0/8', '10.0.0.0/8'],
'explicitly empty' => ['local', '', null],
]);
test('the test suite never sends real ntfy pushes', function () {
expect(config('monitoring.alerts.ntfy.url'))->toBeEmpty()
->and(config('monitoring.alerts.ntfy.topic'))->toBeEmpty()
->and(config('monitoring.alerts.ntfy.token'))->toBeEmpty();
});

View file

@ -1,14 +1,18 @@
<?php
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PaymentOption;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\CurrentPortalContext;
use App\Support\WebPageCache;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/*
@ -86,6 +90,34 @@ function subscribeUserToPlan(User $user, Plan $plan, string $interval = 'monthly
* `web.`-Seiten-Caches auf einmal. Die Tests prüfen weiterhin die fachliche
* Key-Struktur, nur eben generationsunabhängig.
*/
/**
* API-Zugang setzt eine laufende Buchung voraus (EnsureApiAccessIsBooked,
* 08.10.2026). Für API-Tests, die nicht die Buchung selbst prüfen: aktive
* Zahlungsoption anlegen.
*/
function grantApiAccess(User $user): User
{
UserPaymentOption::factory()->create([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create()->id,
'status' => UserPaymentOptionStatus::Active->value,
'current_period_start' => now()->subDay(),
'current_period_end' => now()->addMonth(),
]);
return $user;
}
/**
* Sanctum::actingAs() für einen Nutzer mit gebuchtem API-Zugang.
*
* @param list<string> $abilities
*/
function apiActingAs(User $user, array $abilities = ['*']): User
{
return Sanctum::actingAs(grantApiAccess($user), $abilities);
}
function webPageCacheKey(string $key): string
{
return 'web.g'.WebPageCache::generation().'.'.substr($key, strlen('web.'));