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