1+ <?php
2+ namespace Tests \Unit ;
3+ /**
4+ * Copyright 2026 OpenStack Foundation
5+ * Licensed under the Apache License, Version 2.0 (the "License");
6+ * you may not use this file except in compliance with the License.
7+ * You may obtain a copy of the License at
8+ * http://www.apache.org/licenses/LICENSE-2.0
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ **/
15+
16+ use App \Jobs \EmitAuditLogJob ;
17+ use App \libs \Auth \Models \TwoFactorAuditLog ;
18+ use App \Services \Auth \TwoFactorAuditService ;
19+ use Auth \Repositories \ITwoFactorAuditLogRepository ;
20+ use Auth \User ;
21+ use Illuminate \Support \Facades \Config ;
22+ use Illuminate \Support \Facades \Queue ;
23+ use Mockery ;
24+ use Tests \TestCase ;
25+
26+ /**
27+ * Class TwoFactorAuditServiceTest
28+ * @package Tests\Unit
29+ */
30+ final class TwoFactorAuditServiceTest extends TestCase
31+ {
32+ /** @var TwoFactorAuditService */
33+ private TwoFactorAuditService $ service ;
34+
35+ /** @var \Mockery\MockInterface&ITwoFactorAuditLogRepository */
36+ private $ repository ;
37+
38+ /** @var \Mockery\MockInterface&User */
39+ private $ user ;
40+
41+ protected function setUp (): void
42+ {
43+ parent ::setUp ();
44+ Queue::fake ();
45+
46+ $ this ->repository = Mockery::mock (ITwoFactorAuditLogRepository::class);
47+ $ this ->service = new TwoFactorAuditService ($ this ->repository );
48+
49+ $ this ->user = Mockery::mock (User::class);
50+ $ this ->user ->shouldReceive ('getId ' )->andReturn (42 );
51+ }
52+
53+ protected function tearDown (): void
54+ {
55+ Mockery::close ();
56+ parent ::tearDown ();
57+ }
58+
59+ // -------------------------------------------------------------------------
60+ // log() persists TwoFactorAuditLog with correct fields
61+ // -------------------------------------------------------------------------
62+
63+ public function testLogPersistsTwoFactorAuditLogWithCorrectFields (): void
64+ {
65+ /** @var TwoFactorAuditLog|null $persisted */
66+ $ persisted = null ;
67+
68+ $ this ->repository
69+ ->shouldReceive ('add ' )
70+ ->once ()
71+ ->withArgs (function (TwoFactorAuditLog $ log , bool $ sync ) use (&$ persisted ) {
72+ $ persisted = $ log ;
73+ return $ sync === true ;
74+ });
75+
76+ $ this ->service ->log (
77+ $ this ->user ,
78+ TwoFactorAuditLog::EventChallengeSucceeded,
79+ TwoFactorAuditLog::MethodEmailOtp,
80+ '127.0.0.1 ' ,
81+ ['attempt ' => 1 ]
82+ );
83+
84+ $ this ->assertNotNull ($ persisted );
85+ $ this ->assertSame ($ this ->user , $ persisted ->getUser ());
86+ $ this ->assertSame (TwoFactorAuditLog::EventChallengeSucceeded, $ persisted ->getEventType ());
87+ $ this ->assertSame (TwoFactorAuditLog::MethodEmailOtp, $ persisted ->getMethod ());
88+ $ this ->assertSame ('127.0.0.1 ' , $ persisted ->getIpAddress ());
89+ $ this ->assertSame (['attempt ' => 1 ], $ persisted ->getMetadata ());
90+ }
91+
92+ // -------------------------------------------------------------------------
93+ // log() emits OTLP attributes
94+ // -------------------------------------------------------------------------
95+
96+ public function testLogEmitsOtlpAttributes (): void
97+ {
98+ Config::set ('opentelemetry.enabled ' , true );
99+
100+ $ this ->repository ->shouldReceive ('add ' )->once ();
101+
102+ $ this ->service ->log (
103+ $ this ->user ,
104+ TwoFactorAuditLog::EventChallengeSucceeded,
105+ TwoFactorAuditLog::MethodEmailOtp,
106+ '10.0.0.1 '
107+ );
108+
109+ Queue::assertPushed (EmitAuditLogJob::class, function (EmitAuditLogJob $ job ) {
110+ return $ job ->logMessage === 'two_factor.audit '
111+ && $ job ->auditData ['two_factor.event_type ' ] === TwoFactorAuditLog::EventChallengeSucceeded
112+ && $ job ->auditData ['two_factor.method ' ] === TwoFactorAuditLog::MethodEmailOtp
113+ && $ job ->auditData ['two_factor.user_id ' ] === 42
114+ && $ job ->auditData ['two_factor.ip_address ' ] === '10.0.0.1 '
115+ && $ job ->auditData ['two_factor.success ' ] === true
116+ && $ job ->auditData ['two_factor.device_trusted ' ] === false ;
117+ });
118+ }
119+
120+ // -------------------------------------------------------------------------
121+ // log() emits two_factor.success = false for challenge_failed
122+ // -------------------------------------------------------------------------
123+
124+ public function testLogEmitsSuccessFalseForChallengeFailed (): void
125+ {
126+ Config::set ('opentelemetry.enabled ' , true );
127+
128+ $ this ->repository ->shouldReceive ('add ' )->once ();
129+
130+ $ this ->service ->log (
131+ $ this ->user ,
132+ TwoFactorAuditLog::EventChallengeFailed,
133+ TwoFactorAuditLog::MethodEmailOtp,
134+ '10.0.0.1 '
135+ );
136+
137+ Queue::assertPushed (EmitAuditLogJob::class, function (EmitAuditLogJob $ job ) {
138+ return $ job ->auditData ['two_factor.event_type ' ] === TwoFactorAuditLog::EventChallengeFailed
139+ && $ job ->auditData ['two_factor.success ' ] === false ;
140+ });
141+ }
142+
143+ // -------------------------------------------------------------------------
144+ // log() accepts null metadata
145+ // -------------------------------------------------------------------------
146+
147+ public function testLogAcceptsNullMetadata (): void
148+ {
149+ /** @var TwoFactorAuditLog|null $persisted */
150+ $ persisted = null ;
151+
152+ $ this ->repository
153+ ->shouldReceive ('add ' )
154+ ->once ()
155+ ->withArgs (function (TwoFactorAuditLog $ log ) use (&$ persisted ) {
156+ $ persisted = $ log ;
157+ return true ;
158+ });
159+
160+ $ this ->service ->log (
161+ $ this ->user ,
162+ TwoFactorAuditLog::EventChallengeIssued,
163+ TwoFactorAuditLog::MethodTotp,
164+ '192.168.1.1 ' ,
165+ null
166+ );
167+
168+ $ this ->assertNotNull ($ persisted );
169+ $ this ->assertNull ($ persisted ->getMetadata ());
170+ }
171+
172+ // -------------------------------------------------------------------------
173+ // invalid event type throws InvalidArgumentException
174+ // -------------------------------------------------------------------------
175+
176+ public function testInvalidEventTypeThrowsInvalidArgumentException (): void
177+ {
178+ $ this ->expectException (\InvalidArgumentException::class);
179+
180+ $ this ->repository ->shouldNotReceive ('add ' );
181+
182+ $ this ->service ->log (
183+ $ this ->user ,
184+ 'not_a_valid_event ' ,
185+ TwoFactorAuditLog::MethodEmailOtp,
186+ '127.0.0.1 '
187+ );
188+ }
189+
190+ // -------------------------------------------------------------------------
191+ // invalid method throws InvalidArgumentException
192+ // -------------------------------------------------------------------------
193+
194+ public function testInvalidMethodThrowsInvalidArgumentException (): void
195+ {
196+ $ this ->expectException (\InvalidArgumentException::class);
197+
198+ $ this ->repository ->shouldNotReceive ('add ' );
199+
200+ $ this ->service ->log (
201+ $ this ->user ,
202+ TwoFactorAuditLog::EventChallengeIssued,
203+ 'not_a_valid_method ' ,
204+ '127.0.0.1 '
205+ );
206+ }
207+ }
0 commit comments