Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
PHP
308 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\V1;
|
|
|
|
use App\Enums\EmailChangeStatus;
|
|
use App\Enums\EmailTemplateType;
|
|
use App\Jobs\SendTransactionalEmail;
|
|
use App\Mail\EmailChangedConfirmationMail;
|
|
use App\Models\EmailChangeRequest;
|
|
use App\Models\Organisation;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Tests\TestCase;
|
|
|
|
class EmailChangeTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
// ─── Self-Service Email Change ──────────────────────────────────────
|
|
|
|
public function test_user_can_request_email_change(): void
|
|
{
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create([
|
|
'email' => 'oud@voorbeeld.nl',
|
|
'password' => bcrypt('wachtwoord123'),
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'password' => 'wachtwoord123',
|
|
'app' => 'app',
|
|
]);
|
|
|
|
$response->assertOk();
|
|
|
|
$this->assertDatabaseHas('email_change_requests', [
|
|
'user_id' => $user->id,
|
|
'current_email' => 'oud@voorbeeld.nl',
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
Queue::assertPushed(SendTransactionalEmail::class, function ($job) {
|
|
return $job->recipientEmail === 'nieuw@voorbeeld.nl'
|
|
&& $job->type === EmailTemplateType::EMAIL_VERIFICATION;
|
|
});
|
|
}
|
|
|
|
public function test_email_change_requires_correct_password(): void
|
|
{
|
|
$user = User::factory()->create([
|
|
'password' => bcrypt('wachtwoord123'),
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'password' => 'foutwachtwoord',
|
|
'app' => 'app',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors('password');
|
|
}
|
|
|
|
public function test_email_change_rejects_already_used_email(): void
|
|
{
|
|
User::factory()->create(['email' => 'bezet@voorbeeld.nl']);
|
|
|
|
$user = User::factory()->create([
|
|
'password' => bcrypt('wachtwoord123'),
|
|
]);
|
|
|
|
$response = $this->actingAs($user)->postJson('/api/v1/me/change-email', [
|
|
'new_email' => 'bezet@voorbeeld.nl',
|
|
'password' => 'wachtwoord123',
|
|
'app' => 'app',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors('new_email');
|
|
}
|
|
|
|
public function test_email_change_requires_authentication(): void
|
|
{
|
|
$response = $this->postJson('/api/v1/me/change-email', [
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'password' => 'wachtwoord123',
|
|
'app' => 'app',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
public function test_duplicate_pending_request_cancels_old_one(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$user = User::factory()->create([
|
|
'email' => 'oud@voorbeeld.nl',
|
|
'password' => bcrypt('wachtwoord123'),
|
|
]);
|
|
|
|
// First request
|
|
$this->actingAs($user)->postJson('/api/v1/me/change-email', [
|
|
'new_email' => 'eerste@voorbeeld.nl',
|
|
'password' => 'wachtwoord123',
|
|
'app' => 'app',
|
|
]);
|
|
|
|
$firstRequest = EmailChangeRequest::where('new_email', 'eerste@voorbeeld.nl')->first();
|
|
$this->assertEquals('pending', $firstRequest->status->value);
|
|
|
|
// Second request
|
|
$this->actingAs($user)->postJson('/api/v1/me/change-email', [
|
|
'new_email' => 'tweede@voorbeeld.nl',
|
|
'password' => 'wachtwoord123',
|
|
'app' => 'app',
|
|
]);
|
|
|
|
$firstRequest->refresh();
|
|
$this->assertEquals('cancelled', $firstRequest->status->value);
|
|
|
|
$secondRequest = EmailChangeRequest::where('new_email', 'tweede@voorbeeld.nl')->first();
|
|
$this->assertEquals('pending', $secondRequest->status->value);
|
|
}
|
|
|
|
// ─── Email Change Verification ──────────────────────────────────────
|
|
|
|
public function test_verify_email_change_with_valid_token(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
|
|
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
|
|
|
|
EmailChangeRequest::create([
|
|
'user_id' => $user->id,
|
|
'current_email' => 'oud@voorbeeld.nl',
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'token' => hash('sha256', $plainToken),
|
|
'requested_by_user_id' => $user->id,
|
|
'status' => EmailChangeStatus::PENDING,
|
|
'expires_at' => now()->addHours(24),
|
|
]);
|
|
|
|
$response = $this->postJson('/api/v1/verify-email-change', [
|
|
'token' => $plainToken,
|
|
]);
|
|
|
|
$response->assertOk();
|
|
|
|
$user->refresh();
|
|
$this->assertEquals('nieuw@voorbeeld.nl', $user->email);
|
|
|
|
Mail::assertQueued(EmailChangedConfirmationMail::class, function ($mail) {
|
|
return $mail->hasTo('oud@voorbeeld.nl');
|
|
});
|
|
}
|
|
|
|
public function test_verify_email_change_revokes_all_tokens(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
|
|
$user->createToken('test-token');
|
|
|
|
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
|
|
|
|
EmailChangeRequest::create([
|
|
'user_id' => $user->id,
|
|
'current_email' => 'oud@voorbeeld.nl',
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'token' => hash('sha256', $plainToken),
|
|
'requested_by_user_id' => $user->id,
|
|
'status' => EmailChangeStatus::PENDING,
|
|
'expires_at' => now()->addHours(24),
|
|
]);
|
|
|
|
$this->postJson('/api/v1/verify-email-change', [
|
|
'token' => $plainToken,
|
|
]);
|
|
|
|
$this->assertCount(0, $user->tokens()->get());
|
|
}
|
|
|
|
public function test_verify_email_change_with_expired_token(): void
|
|
{
|
|
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
|
|
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
|
|
|
|
EmailChangeRequest::create([
|
|
'user_id' => $user->id,
|
|
'current_email' => 'oud@voorbeeld.nl',
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'token' => hash('sha256', $plainToken),
|
|
'requested_by_user_id' => $user->id,
|
|
'status' => EmailChangeStatus::PENDING,
|
|
'expires_at' => now()->subHour(),
|
|
]);
|
|
|
|
$response = $this->postJson('/api/v1/verify-email-change', [
|
|
'token' => $plainToken,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors('token');
|
|
}
|
|
|
|
public function test_verify_email_change_with_invalid_token(): void
|
|
{
|
|
$response = $this->postJson('/api/v1/verify-email-change', [
|
|
'token' => 'completely-invalid-token',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors('token');
|
|
}
|
|
|
|
public function test_verify_email_change_fails_if_email_taken_between_request_and_verify(): void
|
|
{
|
|
$user = User::factory()->create(['email' => 'oud@voorbeeld.nl']);
|
|
$plainToken = 'test-token-12345678901234567890123456789012345678901234567890123456';
|
|
|
|
EmailChangeRequest::create([
|
|
'user_id' => $user->id,
|
|
'current_email' => 'oud@voorbeeld.nl',
|
|
'new_email' => 'nieuw@voorbeeld.nl',
|
|
'token' => hash('sha256', $plainToken),
|
|
'requested_by_user_id' => $user->id,
|
|
'status' => EmailChangeStatus::PENDING,
|
|
'expires_at' => now()->addHours(24),
|
|
]);
|
|
|
|
// Another user takes the email
|
|
User::factory()->create(['email' => 'nieuw@voorbeeld.nl']);
|
|
|
|
$response = $this->postJson('/api/v1/verify-email-change', [
|
|
'token' => $plainToken,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors('new_email');
|
|
}
|
|
|
|
// ─── Admin Email Change ─────────────────────────────────────────────
|
|
|
|
public function test_admin_can_change_member_email(): void
|
|
{
|
|
Queue::fake();
|
|
|
|
$organisation = Organisation::factory()->create();
|
|
$admin = User::factory()->create();
|
|
$organisation->users()->attach($admin, ['role' => 'org_admin']);
|
|
|
|
$member = User::factory()->create(['email' => 'lid@voorbeeld.nl']);
|
|
$organisation->users()->attach($member, ['role' => 'org_member']);
|
|
|
|
$response = $this->actingAs($admin)->postJson(
|
|
"/api/v1/organisations/{$organisation->id}/members/{$member->id}/change-email",
|
|
['new_email' => 'nieuw-lid@voorbeeld.nl'],
|
|
);
|
|
|
|
$response->assertOk();
|
|
|
|
Queue::assertPushed(SendTransactionalEmail::class, function ($job) {
|
|
return $job->recipientEmail === 'nieuw-lid@voorbeeld.nl'
|
|
&& $job->type === EmailTemplateType::EMAIL_VERIFICATION;
|
|
});
|
|
}
|
|
|
|
public function test_non_admin_cannot_change_member_email(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$member = User::factory()->create();
|
|
$organisation->users()->attach($member, ['role' => 'org_member']);
|
|
|
|
$otherMember = User::factory()->create(['email' => 'ander@voorbeeld.nl']);
|
|
$organisation->users()->attach($otherMember, ['role' => 'org_member']);
|
|
|
|
$response = $this->actingAs($member)->postJson(
|
|
"/api/v1/organisations/{$organisation->id}/members/{$otherMember->id}/change-email",
|
|
['new_email' => 'nieuw@voorbeeld.nl'],
|
|
);
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
public function test_unauthenticated_cannot_change_member_email(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$member = User::factory()->create();
|
|
$organisation->users()->attach($member, ['role' => 'org_member']);
|
|
|
|
$response = $this->postJson(
|
|
"/api/v1/organisations/{$organisation->id}/members/{$member->id}/change-email",
|
|
['new_email' => 'nieuw@voorbeeld.nl'],
|
|
);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
}
|