api: Newsletter-Anmeldung über die API eingestellt (410)
Der Endpunkt legte Abos ohne Bestätigungsweg an, die Spezifikation versprach ein Double-Opt-in; seit dem Go-Live kein Aufruf. Antwortet jetzt mit 410, die Ability ist für neue Tokens nicht mehr wählbar (Roadmap 4.3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
002dc08b5c
commit
ccef83b952
6 changed files with 38 additions and 214 deletions
|
|
@ -212,25 +212,12 @@ paths:
|
||||||
/newsletter/subscribe:
|
/newsletter/subscribe:
|
||||||
post:
|
post:
|
||||||
tags: [Newsletter]
|
tags: [Newsletter]
|
||||||
summary: Subscribe an email address
|
deprecated: true
|
||||||
description: Requires `newsletter:subscribe`.
|
summary: Discontinued — newsletter subscription via the API
|
||||||
requestBody:
|
description: Discontinued on 2026-10-09. Responds with 410 and creates no subscription.
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SubscribeNewsletterRequest'
|
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'410':
|
||||||
description: Subscription created.
|
description: Endpoint discontinued.
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/NewsletterSubscriptionResponse'
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'422':
|
|
||||||
$ref: '#/components/responses/ValidationError'
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
sanctumBearer:
|
sanctumBearer:
|
||||||
|
|
|
||||||
|
|
@ -3,78 +3,21 @@
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
|
||||||
use App\Models\NewsletterSubscription;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newsletter-Anmeldung über die API — abgeschaltet am 09.10.2026
|
||||||
|
* (Roadmap 4.3). Der Endpunkt legte Abos ohne Bestätigungsweg an, obwohl die
|
||||||
|
* Spezifikation ein Double-Opt-in versprach, und wurde seit dem Go-Live nicht
|
||||||
|
* genutzt. Eine Anmeldung mit Double-Opt-in kommt mit dem Branchen-Digest
|
||||||
|
* (Roadmap 6.4); bis dahin antwortet die Route mit 410.
|
||||||
|
*/
|
||||||
class NewsletterSubscriptionController extends Controller
|
class NewsletterSubscriptionController extends Controller
|
||||||
{
|
{
|
||||||
public function store(SubscribeNewsletterRequest $request): JsonResponse
|
public function store(): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
|
||||||
|
|
||||||
$email = mb_strtolower($validated['email']);
|
|
||||||
|
|
||||||
$subscription = DB::transaction(function () use ($request, $validated, $email): NewsletterSubscription {
|
|
||||||
$subscription = NewsletterSubscription::withoutGlobalScopes()
|
|
||||||
->where('portal', $validated['portal'])
|
|
||||||
->where('email', $email)
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// Nur ein fremder, tatsächlich gesetzter Eigentümer blockiert (409).
|
|
||||||
// Verwaiste Datensätze (user_id NULL nach Konto-Löschung/Legacy-Import)
|
|
||||||
// dürfen neu gebunden werden — sonst wäre die portal+email-Kombination
|
|
||||||
// dauerhaft nicht mehr abonnierbar.
|
|
||||||
if ($subscription !== null && $subscription->user_id !== null && $subscription->user_id !== $request->user()->id) {
|
|
||||||
abort(409, 'This email address is already subscribed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($subscription === null) {
|
|
||||||
return NewsletterSubscription::withoutGlobalScopes()->create([
|
|
||||||
'portal' => $validated['portal'],
|
|
||||||
'email' => $email,
|
|
||||||
'user_id' => $request->user()->id,
|
|
||||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
|
||||||
'first_name' => $validated['first_name'] ?? null,
|
|
||||||
'last_name' => $validated['last_name'] ?? null,
|
|
||||||
'ip_address' => $request->ip(),
|
|
||||||
'is_confirmed' => false,
|
|
||||||
'confirmation_token' => Str::random(32),
|
|
||||||
'subscribed_at' => now(),
|
|
||||||
'unsubscribed_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attributes = [
|
|
||||||
'user_id' => $request->user()->id,
|
|
||||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
|
||||||
'first_name' => $validated['first_name'] ?? null,
|
|
||||||
'last_name' => $validated['last_name'] ?? null,
|
|
||||||
'ip_address' => $request->ip(),
|
|
||||||
'subscribed_at' => now(),
|
|
||||||
'unsubscribed_at' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! $subscription->is_confirmed) {
|
|
||||||
$attributes['confirmation_token'] = Str::random(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
$subscription->update($attributes);
|
|
||||||
|
|
||||||
return $subscription;
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Newsletter subscription created.',
|
'message' => 'Newsletter subscription via the API has been discontinued.',
|
||||||
'data' => [
|
], 410);
|
||||||
'id' => $subscription->id,
|
|
||||||
'portal' => $subscription->portal->value,
|
|
||||||
'email' => $subscription->email,
|
|
||||||
'is_confirmed' => $subscription->is_confirmed,
|
|
||||||
],
|
|
||||||
], 201);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\V1;
|
|
||||||
|
|
||||||
use App\Enums\Portal;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class SubscribeNewsletterRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Determine if the user is authorized to make this request.
|
|
||||||
*/
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return $this->user()?->tokenCan('newsletter:subscribe') === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'portal' => ['required', Rule::in([
|
|
||||||
Portal::Presseecho->value,
|
|
||||||
Portal::Businessportal24->value,
|
|
||||||
])],
|
|
||||||
'email' => ['required', 'email', 'max:190'],
|
|
||||||
'salutation_key' => ['nullable', 'string', 'max:20'],
|
|
||||||
'first_name' => ['nullable', 'string', 'max:80'],
|
|
||||||
'last_name' => ['nullable', 'string', 'max:80'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,7 +40,7 @@ tags:
|
||||||
- name: categories
|
- name: categories
|
||||||
description: "Aktive Rubriken (Ability: press-releases:read)"
|
description: "Aktive Rubriken (Ability: press-releases:read)"
|
||||||
- name: newsletter
|
- name: newsletter
|
||||||
description: "Newsletter-Anmeldungen (Ability: newsletter:subscribe)"
|
description: "Newsletter-Anmeldung über die API — eingestellt am 09.10.2026"
|
||||||
paths:
|
paths:
|
||||||
/press-releases:
|
/press-releases:
|
||||||
get:
|
get:
|
||||||
|
|
@ -257,24 +257,13 @@ paths:
|
||||||
/newsletter/subscribe:
|
/newsletter/subscribe:
|
||||||
post:
|
post:
|
||||||
tags: [newsletter]
|
tags: [newsletter]
|
||||||
summary: Newsletter-Anmeldung anlegen (Double-Opt-in folgt per Mail)
|
deprecated: true
|
||||||
requestBody:
|
summary: Eingestellt — Newsletter-Anmeldung über die API
|
||||||
required: true
|
description: >
|
||||||
content:
|
Seit 09.10.2026 abgeschaltet. Die Route antwortet mit 410 und legt
|
||||||
application/json:
|
keine Anmeldung an.
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [portal, email]
|
|
||||||
properties:
|
|
||||||
portal:
|
|
||||||
type: string
|
|
||||||
enum: [presseecho, businessportal24]
|
|
||||||
email: { type: string, format: email, maxLength: 190 }
|
|
||||||
salutation_key: { type: string, maxLength: 20 }
|
|
||||||
first_name: { type: string, maxLength: 80 }
|
|
||||||
last_name: { type: string, maxLength: 80 }
|
|
||||||
responses:
|
responses:
|
||||||
"201": { description: "Anmeldung angelegt (unbestätigt)" }
|
"410": { description: "Endpunkt eingestellt" }
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerToken:
|
bearerToken:
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
||||||
'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten',
|
'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten',
|
||||||
'press-release-images:write' => 'Bilder zu Pressemitteilungen verwalten',
|
'press-release-images:write' => 'Bilder zu Pressemitteilungen verwalten',
|
||||||
'companies:read' => 'Firmendaten lesen',
|
'companies:read' => 'Firmendaten lesen',
|
||||||
'newsletter:subscribe' => 'Newsletter-Anmeldungen auslösen',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function createToken(): void
|
public function createToken(): void
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\NewsletterSubscription;
|
use App\Models\NewsletterSubscription;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
test('legacy api keys in query parameters are not accepted but get the migration hint', function () {
|
test('legacy api keys in query parameters are not accepted but get the migration hint', function () {
|
||||||
|
|
@ -51,86 +52,22 @@ test('api user can list categories with press release read ability', function ()
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('api user can subscribe an email to newsletter', function () {
|
test('the newsletter subscription endpoint is discontinued and creates nothing (Roadmap 4.3)', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
apiActingAs($user, ['newsletter:subscribe']);
|
apiActingAs($user, ['newsletter:subscribe']);
|
||||||
|
|
||||||
$this->postJson('/api/v1/newsletter/subscribe', [
|
$this->postJson('/api/v1/newsletter/subscribe', [
|
||||||
'portal' => 'presseecho',
|
'portal' => 'presseecho',
|
||||||
'email' => 'newsletter@example.com',
|
'email' => 'newsletter@example.com',
|
||||||
'first_name' => 'Max',
|
])->assertStatus(410);
|
||||||
'last_name' => 'Muster',
|
|
||||||
])
|
|
||||||
->assertCreated()
|
|
||||||
->assertJsonPath('data.email', 'newsletter@example.com')
|
|
||||||
->assertJsonPath('data.is_confirmed', false);
|
|
||||||
|
|
||||||
expect(NewsletterSubscription::withoutGlobalScopes()
|
expect(NewsletterSubscription::withoutGlobalScopes()->where('email', 'newsletter@example.com')->exists())->toBeFalse();
|
||||||
->where('email', 'newsletter@example.com')
|
|
||||||
->where('portal', 'presseecho')
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resubscribing keeps an existing confirmation intact', function () {
|
test('the discontinued endpoint leaves existing subscriptions untouched', function () {
|
||||||
/** @var TestCase $this */
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->create([
|
|
||||||
'portal' => 'presseecho',
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'email' => 'confirmed@example.com',
|
|
||||||
'is_confirmed' => true,
|
|
||||||
'confirmation_token' => null,
|
|
||||||
'subscribed_at' => now()->subMonth(),
|
|
||||||
'unsubscribed_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
apiActingAs($user, ['newsletter:subscribe']);
|
|
||||||
|
|
||||||
$this->postJson('/api/v1/newsletter/subscribe', [
|
|
||||||
'portal' => 'presseecho',
|
|
||||||
'email' => 'confirmed@example.com',
|
|
||||||
'first_name' => 'Neu',
|
|
||||||
])->assertCreated()
|
|
||||||
->assertJsonPath('data.is_confirmed', true);
|
|
||||||
|
|
||||||
$subscription->refresh();
|
|
||||||
|
|
||||||
expect($subscription->is_confirmed)->toBeTrue()
|
|
||||||
->and($subscription->confirmation_token)->toBeNull()
|
|
||||||
->and($subscription->unsubscribed_at)->toBeNull()
|
|
||||||
->and($subscription->first_name)->toBe('Neu');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an orphaned subscription without owner can be rebound instead of being permanently blocked', function () {
|
|
||||||
/** @var TestCase $this */
|
|
||||||
// user_id NULL entsteht z. B. nach Konto-Löschung (FK nullOnDelete) oder
|
|
||||||
// durch Legacy-Import. Ohne Rebind wäre portal+email dauerhaft gesperrt.
|
|
||||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->create([
|
|
||||||
'portal' => 'presseecho',
|
|
||||||
'user_id' => null,
|
|
||||||
'email' => 'orphan@example.com',
|
|
||||||
'is_confirmed' => false,
|
|
||||||
'subscribed_at' => now()->subMonth(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
apiActingAs($user, ['newsletter:subscribe']);
|
|
||||||
|
|
||||||
$this->postJson('/api/v1/newsletter/subscribe', [
|
|
||||||
'portal' => 'presseecho',
|
|
||||||
'email' => 'orphan@example.com',
|
|
||||||
'first_name' => 'Wieder da',
|
|
||||||
])->assertCreated();
|
|
||||||
|
|
||||||
expect($subscription->fresh()->user_id)->toBe($user->id)
|
|
||||||
->and($subscription->fresh()->first_name)->toBe('Wieder da');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an api user cannot take over another newsletter subscription', function () {
|
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
$owner = User::factory()->create();
|
$owner = User::factory()->create();
|
||||||
$attacker = User::factory()->create();
|
|
||||||
$subscription = NewsletterSubscription::withoutGlobalScopes()->create([
|
$subscription = NewsletterSubscription::withoutGlobalScopes()->create([
|
||||||
'portal' => 'presseecho',
|
'portal' => 'presseecho',
|
||||||
'user_id' => $owner->id,
|
'user_id' => $owner->id,
|
||||||
|
|
@ -139,15 +76,22 @@ test('an api user cannot take over another newsletter subscription', function ()
|
||||||
'is_confirmed' => true,
|
'is_confirmed' => true,
|
||||||
'subscribed_at' => now(),
|
'subscribed_at' => now(),
|
||||||
]);
|
]);
|
||||||
apiActingAs($attacker, ['newsletter:subscribe']);
|
apiActingAs(User::factory()->create(), ['newsletter:subscribe']);
|
||||||
|
|
||||||
$this->postJson('/api/v1/newsletter/subscribe', [
|
$this->postJson('/api/v1/newsletter/subscribe', [
|
||||||
'portal' => 'presseecho',
|
'portal' => 'presseecho',
|
||||||
'email' => 'owned@example.com',
|
'email' => 'owned@example.com',
|
||||||
'first_name' => 'Übernommen',
|
'first_name' => 'Übernommen',
|
||||||
])->assertConflict();
|
])->assertStatus(410);
|
||||||
|
|
||||||
expect($subscription->fresh()->user_id)->toBe($owner->id)
|
expect($subscription->fresh()->user_id)->toBe($owner->id)
|
||||||
->and($subscription->fresh()->first_name)->toBe('Original')
|
->and($subscription->fresh()->first_name)->toBe('Original');
|
||||||
->and($subscription->fresh()->is_confirmed)->toBeTrue();
|
});
|
||||||
|
|
||||||
|
test('new api tokens can no longer request the newsletter ability', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->actingAs(User::factory()->create(['is_active' => true]));
|
||||||
|
|
||||||
|
Livewire::test('customer.tokens')
|
||||||
|
->assertDontSee('Newsletter-Anmeldungen auslösen');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue