Compare commits
4 commits
5dc3bf52d2
...
6d85d2982a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d85d2982a | |||
| 7fa310fa76 | |||
| 6eb7c08863 | |||
| c76e420f1d |
37 changed files with 581 additions and 96 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
app/Http/Middleware/EnsureApiAccessIsBooked.php
Normal file
38
app/Http/Middleware/EnsureApiAccessIsBooked.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
23
config/security.php
Normal 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
22
config/trustedproxy.php
Normal 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,
|
||||
|
||||
];
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
10
phpunit.xml
10
phpunit.xml
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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]+'])
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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]+'])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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}", [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue