seed(RolePermissionSeeder::class); $this->seed(IdentityMessageDefinitionSeeder::class); $this->user = User::factory()->create([ 'email' => 'purpose-gate@test.com', 'password' => Hash::make('Passw0rd!2fa'), 'status' => UserStatus::Active->value, ]); config(['g7_settings.core.security.two_factor_auth' => true]); } /** * @scenario two_factor=on, controller=user, code=consumed_by_public_verify * * @effects public_verify_rejects_login_purpose */ #[Test] public function the_public_verify_endpoint_refuses_a_login_challenge(): void { $challengeId = $this->startLoginChallenge(); $response = $this->postJson("/api/identity/challenges/{$challengeId}/verify", [ 'code' => $this->issuedCode($challengeId), ]); $response->assertStatus(403); $this->assertSame('PURPOSE_NOT_ALLOWED', $response->json('errors.failure_code')); } /** * @scenario two_factor=on, controller=user, code=consumed_by_public_verify * * @effects public_cancel_rejects_login_purpose */ #[Test] public function the_public_cancel_endpoint_refuses_a_login_challenge(): void { $challengeId = $this->startLoginChallenge(); $this->postJson("/api/identity/challenges/{$challengeId}/cancel") ->assertStatus(403); } /** * @scenario two_factor=on, controller=user, code=valid * * @effects login_completes_after_public_endpoints_refuse */ #[Test] public function a_login_challenge_still_completes_after_the_public_endpoints_refuse_it(): void { $challengeId = $this->startLoginChallenge(); $code = $this->issuedCode($challengeId); $this->postJson("/api/identity/challenges/{$challengeId}/verify", ['code' => $code]); $this->postJson("/api/identity/challenges/{$challengeId}/cancel"); // 게이트가 상태를 전혀 바꾸지 않아야 로그인이 그대로 완료된다. $response = $this->postJson('/api/auth/login/two-factor', [ 'challenge_id' => $challengeId, 'code' => $code, ]); $response->assertStatus(200); $this->assertNotEmpty($response->json('data.token')); } /** * @scenario two_factor=on, controller=user, code=valid * * @effects signup_purpose_unaffected_by_login_gate */ #[Test] public function a_signup_challenge_is_unaffected_by_the_gate(): void { // 대조군 — 게이트가 로그인 이외 목적까지 막으면 가입·비밀번호 재설정 화면이 통째로 멈춘다. $challenge = app(IdentityVerificationService::class)->start( IdentityVerificationPurpose::Signup->value, $this->user, ['origin_type' => 'route', 'origin_identifier' => 'test'] ); $response = $this->postJson("/api/identity/challenges/{$challenge->id}/verify", [ 'code' => $this->issuedCode($challenge->id), ]); $this->assertNotSame(403, $response->getStatusCode(), '로그인 이외 목적까지 막으면 가입 흐름이 멈춥니다.'); } /** * 로그인 2단계 인증 challenge 를 발급하고 그 id 를 돌려줍니다. * * @return string challenge UUID */ private function startLoginChallenge(): string { return $this->postJson('/api/auth/login', [ 'email' => 'purpose-gate@test.com', 'password' => 'Passw0rd!2fa', ])->json('data.challenge_id'); } /** * challenge 에 알려진 인증 코드를 심고 그 값을 돌려줍니다. * * @param string $challengeId challenge UUID * @return string 심어 둔 인증 코드 */ private function issuedCode(string $challengeId): string { $code = '135790'; $log = IdentityVerificationLog::find($challengeId); $metadata = $log->metadata ?? []; $metadata['code_hash'] = Hash::make($code); $log->metadata = $metadata; $log->save(); return $code; } }