diff --git a/.env.sample b/.env.sample index c302d13..31df2e6 100644 --- a/.env.sample +++ b/.env.sample @@ -184,4 +184,13 @@ CORS_DEBUG=true APP_SIGNATURE = TOKENTIMEOUT = -JWT_SECRET = \ No newline at end of file +JWT_SECRET = + + +#-------------------------------------------------------------------- +# OTP Configuration +#-------------------------------------------------------------------- + +DEFAULT_OTP = '' +IS_SEND_EMAIL_OTP = +IS_SEND_SMS_OTP = \ No newline at end of file diff --git a/app/Controllers/EmployeeRestController.php b/app/Controllers/EmployeeRestController.php index c5d99b3..8541d19 100755 --- a/app/Controllers/EmployeeRestController.php +++ b/app/Controllers/EmployeeRestController.php @@ -1288,6 +1288,8 @@ class EmployeeRestController extends AdminController // Execute query + + $builder->orderBy('files.id', 'desc'); $data = $builder->get()->getResultArray(); if (!empty($data)) { diff --git a/app/Controllers/RestAuthenticationController.php b/app/Controllers/RestAuthenticationController.php index e29c0a7..c6ea35c 100755 --- a/app/Controllers/RestAuthenticationController.php +++ b/app/Controllers/RestAuthenticationController.php @@ -158,10 +158,17 @@ class RestAuthenticationController extends AdminController } $otp = random_int(100000, 999999); - if($mobile_number == '9442741776') - { + $default_otp = env('DEFAULT_OTP') ?? ''; + $is_send_sms_otp = filter_var(env('IS_SEND_SMS_OTP') ?? true, FILTER_VALIDATE_BOOLEAN); + + if(!empty($default_otp)){ + $otp = $default_otp; + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: Sending default otp"); + } + + if($mobile_number == '9442741776') { $otp = '123456'; - $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: setting 123456 for IOS APP TESTING = " . json_encode($employeeData)); + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: setting 123456 for IOS APP TESTING = " . json_encode($employeeData)); } $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: Final employee data to verify = " . json_encode($employeeData)); @@ -211,13 +218,13 @@ class RestAuthenticationController extends AdminController } //skip sms for live test no - if($mobile_number == '9442741776') - { - + if($mobile_number == '9442741776' || $is_send_sms_otp == false) { + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: skip SMS sent for IOS APP TESTING = " . ($mobile_number)); - $result = ['user_verification' => true ,'message' => "Verified Successfully" ]; - return $this->respond(['status' => 'success','code' => 200,'data' => $result],200); + $result = ['user_verification' => true ,'message' => "Verified Successfully" ]; + return $this->respond(['status' => 'success','code' => 200,'data' => $result, 'is_sms_send' => false],200); } + //send sms $SMSResult = sendOtpSms($mobile_number, $otp); if ($SMSResult['status'] == 'success') @@ -276,6 +283,14 @@ class RestAuthenticationController extends AdminController $otp = random_int(100000, 999999); + $default_otp = env('DEFAULT_OTP') ?? ''; + $is_send_email_otp = filter_var(env('IS_SEND_EMAIL_OTP') ?? true, FILTER_VALIDATE_BOOLEAN); + + if(!empty($default_otp)){ + $otp = $default_otp; + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: Sending default otp"); + } + //only retail policy if(empty($empdata)){ $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithEmailId: No employee data found both PRE & POST"); @@ -387,6 +402,11 @@ class RestAuthenticationController extends AdminController 'mail_type' => 'otp_mail', ]; + if($is_send_email_otp == false){ + $result = ['user_verification' => true, 'message' => "Verified Successfully"]; + return $this->respond(['status' => 'success', 'code' => 200, 'data' => $result, 'is_email_send' => false], 200); + } + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithEmailId: Sending OTP email to " . $employeeData['email_corporate']); $res = $this->sendEmailOtp($employeeData['email_corporate'], $otp, $common); $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithEmailId: Mail response = " . $res); @@ -596,6 +616,13 @@ class RestAuthenticationController extends AdminController $otp = random_int(100000, 999999); + $default_otp = env('DEFAULT_OTP') ?? ''; + $is_send_sms_otp = filter_var(env('IS_SEND_SMS_OTP') ?? true, FILTER_VALIDATE_BOOLEAN); + + if(!empty($default_otp)){ + $otp = $default_otp; + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: Sending default otp"); + } if ($HrData) { @@ -617,6 +644,11 @@ class RestAuthenticationController extends AdminController $data->otp = $otp; $this->callThirdPartyAPI($data, 'updateHROTP'); + if($is_send_sms_otp == false){ + $result = ['user_verification' => true, 'message' => "Verified Successfully"]; + return $this->respond(['status' => 'success', 'code' => 200, 'data' => $result, 'is_sms_send' => false], 200); + } + //send sms $SMSResult = sendOtpSms($mobile_number, $otp); if ($SMSResult['status'] == 'success') @@ -653,6 +685,7 @@ class RestAuthenticationController extends AdminController $data = $this->request->getJSON(); + $email = $data->email; $HrData = $this->hrModel->where('email', $email) @@ -661,7 +694,16 @@ class RestAuthenticationController extends AdminController ->first(); $otp = random_int(100000, 999999); - $data->otp = $otp; + + $default_otp = env('DEFAULT_OTP') ?? ''; + $is_send_email_otp = filter_var(env('IS_SEND_EMAIL_OTP') ?? true, FILTER_VALIDATE_BOOLEAN); + + if(!empty($default_otp)){ + $otp = $default_otp; + $this->myLogger->logme("error", "REST-AUTH-CONTROLLER - verifyEmployeeWithMobileNumber: Sending default otp"); + } + + $data->otp = $otp; if ($HrData) { @@ -685,10 +727,16 @@ class RestAuthenticationController extends AdminController $update = $db->query($sql, [$otp, $email]); if($update) - { + { + $data->otp = $otp; $this->callThirdPartyAPI($data, 'updateHROTP'); + if($is_send_email_otp == false){ + $result = ['user_verification' => true, 'message' => "Verified Successfully"]; + return $this->respond(['status' => 'success', 'code' => 200, 'data' => $result, 'is_email_send' => false], 200); + } + $common = [ 'login_type' => 'HR login', 'login_email' => $email, @@ -1932,7 +1980,7 @@ class RestAuthenticationController extends AdminController } $decoded = $result['decoded']; - $userId = $decoded['id'] ?? null; + $userId = $decoded['pre_hr_id'] ?? null; if (!$userId) { return $this->respond([ diff --git a/app/Filters/AuthJWT.php b/app/Filters/AuthJWT.php index 4eac9e5..00d985d 100755 --- a/app/Filters/AuthJWT.php +++ b/app/Filters/AuthJWT.php @@ -115,9 +115,19 @@ class AuthJWT implements FilterInterface } // Refresh sliding expiration - $model->update($id, [ - 'token_time_out' => time() + getenv('TOKENTIMEOUT') - ]); + $newExpiry = time() + getenv('TOKENTIMEOUT'); + + if (!isset($decoded['emp_code'])) { + // HR user: refresh token_time_out across all branches (same mobile/email) + // so switching branches doesn't cause an unexpected expiry + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + $model->where($field, $user[$field]) + ->where('contact_type', 'client') + ->where('is_active', 1) + ->update(null, ['token_time_out' => $newExpiry]); + } else { + $model->update($id, ['token_time_out' => $newExpiry]); + } return true; } diff --git a/app/Helpers/JWTToken.php b/app/Helpers/JWTToken.php index f7b32ba..1715d54 100755 --- a/app/Helpers/JWTToken.php +++ b/app/Helpers/JWTToken.php @@ -137,7 +137,7 @@ class JWTToken $token = $jwtParts[0]; try { - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return json_encode(['status' => true, 'message' => 'Token is valid', 'data' =>$decoded->data->id ]); } catch (ExpiredException $e) { return json_encode(['status' => false, 'message' => 'Token has expired']); @@ -159,7 +159,7 @@ class JWTToken $jwtParts = explode(' ', $jwt); $token = $jwtParts[1]; - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return $decoded->id; } @@ -170,7 +170,7 @@ class JWTToken $jwtParts = explode(' ', $jwt); $token = $jwtParts[1]; - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return $decoded->role; } @@ -182,7 +182,7 @@ class JWTToken $jwtParts = explode(' ', $jwt); $token = $jwtParts[1]; - $decoded = JWT::decode($token, new Key("secret", 'HS512')); + $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS512')); return $decoded; } diff --git a/app/Views/client_basic_info.php b/app/Views/client_basic_info.php index 29d2c76..9f651ae 100755 --- a/app/Views/client_basic_info.php +++ b/app/Views/client_basic_info.php @@ -238,6 +238,9 @@ input:checked + .slider:before { var message = (PrimaryKey === '') ? 'Client General Info Created successfully' : 'Client General Info Updated successfully'; toastr.success(message, 'Success'); $submitButton.prop('disabled', false); + let client_name = res.data.client_name ? (' - ' + res.data.client_name) : ''; + $('#client_heading').text(client_name); + }, 1000); } diff --git a/app/Views/client_onboarding.php b/app/Views/client_onboarding.php index 08e9359..7f55c01 100755 --- a/app/Views/client_onboarding.php +++ b/app/Views/client_onboarding.php @@ -41,7 +41,7 @@ body {
-

Client Onboarding

+

Client Onboarding

"> diff --git a/tests/unit/HRAuthenticationTest.php b/tests/unit/HRAuthenticationTest.php new file mode 100644 index 0000000..2c1db65 --- /dev/null +++ b/tests/unit/HRAuthenticationTest.php @@ -0,0 +1,607 @@ +jwtSecret}"); + putenv("TOKENTIMEOUT={$this->tokenTimeout}"); + + $_ENV['JWT_SECRET'] = $this->jwtSecret; + $_SERVER['JWT_SECRET'] = $this->jwtSecret; + } + + protected function tearDown(): void + { + putenv('JWT_SECRET'); + putenv('TOKENTIMEOUT'); + unset($_ENV['JWT_SECRET'], $_SERVER['JWT_SECRET']); + + parent::tearDown(); + } + + // ── Helpers ── + + private function createToken(array $payload, string $algo = 'HS512'): string + { + return JWT::encode($payload, $this->jwtSecret, $algo); + } + + private function bearerHeader(array $payload): string + { + return 'Bearer ' . $this->createToken($payload); + } + + private function createMockRequest(string $authHeader): IncomingRequest + { + $request = $this->createMock(IncomingRequest::class); + $request->method('getHeaderLine') + ->with('Authorization') + ->willReturn($authHeader); + + return $request; + } + + // ═══════════════════════════════════════════════ + // 1. validateJWT — Header Format Validation + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsEmptyHeader() + { + $result = JWTToken::validateJWT(''); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_RejectsMissingBearerPrefix() + { + $token = $this->createToken(['id' => 1]); + + $result = JWTToken::validateJWT($token); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_RejectsBasicAuthScheme() + { + $result = JWTToken::validateJWT('Basic dXNlcjpwYXNz'); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_RejectsBearerWithExtraSpaces() + { + $token = $this->createToken(['id' => 1]); + + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + public function testValidateJWT_BearerIsCaseSensitive() + { + $token = $this->createToken(['id' => 1]); + + $result = JWTToken::validateJWT("bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid Authorization header', $result['message']); + } + + // ═══════════════════════════════════════════════ + // 2. validateJWT — JWT Structure Validation + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsMalformedJWT_TwoParts() + { + $result = JWTToken::validateJWT('Bearer header.payload'); + + $this->assertFalse($result['status']); + $this->assertEquals('Malformed JWT', $result['message']); + } + + public function testValidateJWT_RejectsMalformedJWT_FourParts() + { + $result = JWTToken::validateJWT('Bearer a.b.c.d'); + + $this->assertFalse($result['status']); + $this->assertEquals('Malformed JWT', $result['message']); + } + + public function testValidateJWT_RejectsMalformedJWT_SinglePart() + { + $result = JWTToken::validateJWT('Bearer notajwt'); + + $this->assertFalse($result['status']); + $this->assertEquals('Malformed JWT', $result['message']); + } + + // ═══════════════════════════════════════════════ + // 3. validateJWT — Algorithm Security + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsNoneAlgorithm() + { + $header = base64_encode(json_encode(['alg' => 'none', 'typ' => 'JWT'])); + $payload = base64_encode(json_encode(['id' => 1])); + $fakeToken = "$header.$payload."; + + $result = JWTToken::validateJWT("Bearer $fakeToken"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid or unsupported JWT algorithm', $result['message']); + } + + public function testValidateJWT_RejectsMissingAlgorithm() + { + $header = base64_encode(json_encode(['typ' => 'JWT'])); + $payload = base64_encode(json_encode(['id' => 1])); + $fakeToken = "$header.$payload.fakesig"; + + $result = JWTToken::validateJWT("Bearer $fakeToken"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid or unsupported JWT algorithm', $result['message']); + } + + public function testValidateJWT_RejectsHS256Algorithm() + { + $token = JWT::encode(['id' => 1], $this->jwtSecret, 'HS256'); + + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid or unsupported JWT algorithm', $result['message']); + } + + // ═══════════════════════════════════════════════ + // 4. validateJWT — Signature Validation + // ═══════════════════════════════════════════════ + + public function testValidateJWT_RejectsTokenSignedWithWrongSecret() + { + $token = JWT::encode(['id' => 1, 'pre_hr_id' => 5], 'attacker-secret', 'HS512'); + + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertFalse($result['status']); + $this->assertEquals('Invalid token signature', $result['message']); + } + + public function testValidateJWT_RejectsTamperedPayload() + { + $token = $this->createToken(['id' => 1, 'pre_hr_id' => 5]); + $parts = explode('.', $token); + + // Attacker tampers payload to escalate to a different branch + $parts[1] = rtrim(base64_encode(json_encode(['id' => 1, 'pre_hr_id' => 999])), '='); + $tampered = implode('.', $parts); + + $result = JWTToken::validateJWT("Bearer $tampered"); + + $this->assertFalse($result['status']); + } + + // ═══════════════════════════════════════════════ + // 5. validateJWT — Valid Token Decoding + // ═══════════════════════════════════════════════ + + public function testValidateJWT_AcceptsValidHRToken() + { + $payload = [ + 'id' => 10, + 'pre_hr_id' => 5, + 'pre_client_id' => md5('1'), + 'pre_branch_id' => 1, + 'post_client_id' => md5('2'), + 'allowed_modules' => '["dashboard","reports"]', + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + + $this->assertTrue($result['status']); + $this->assertArrayHasKey('decoded', $result); + $this->assertEquals(5, $result['decoded']['pre_hr_id']); + $this->assertEquals(10, $result['decoded']['id']); + $this->assertEquals(1, $result['decoded']['pre_branch_id']); + } + + public function testValidateJWT_AcceptsValidEmployeeToken() + { + $payload = [ + 'id' => 1, + 'emp_code' => 'EMP001', + 'token_type' => 'pre', + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + + $this->assertTrue($result['status']); + $this->assertEquals('EMP001', $result['decoded']['emp_code']); + } + + public function testValidateJWT_PreservesAllPayloadFields() + { + $payload = [ + 'id' => 10, + 'pre_hr_id' => 5, + 'pre_client_id' => 'hashed_client', + 'pre_branch_id' => 3, + 'post_client_id' => 'hashed_post', + 'allowed_modules' => '["m1"]', + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + $decoded = $result['decoded']; + + $this->assertEquals($payload['pre_hr_id'], $decoded['pre_hr_id']); + $this->assertEquals($payload['pre_branch_id'], $decoded['pre_branch_id']); + $this->assertEquals($payload['pre_client_id'], $decoded['pre_client_id']); + $this->assertEquals($payload['post_client_id'], $decoded['post_client_id']); + } + + // ═══════════════════════════════════════════════ + // 6. Token Differentiation (HR vs Employee) + // ═══════════════════════════════════════════════ + + public function testHRToken_DoesNotContainEmpCode() + { + $hrPayload = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + $result = JWTToken::validateJWT($this->bearerHeader($hrPayload)); + + $this->assertTrue($result['status']); + $this->assertArrayNotHasKey('emp_code', $result['decoded']); + } + + public function testEmployeeToken_ContainsEmpCode() + { + $empPayload = ['id' => 1, 'emp_code' => 'EMP001']; + + $result = JWTToken::validateJWT($this->bearerHeader($empPayload)); + + $this->assertTrue($result['status']); + $this->assertArrayHasKey('emp_code', $result['decoded']); + } + + public function testFilterRoutesHRToLevelContactModel() + { + $decoded = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + $this->assertFalse(isset($decoded['emp_code']), 'HR token should NOT have emp_code'); + $id = $decoded['pre_hr_id'] ?? null; + $this->assertEquals(5, $id, 'HR lookup should use pre_hr_id'); + } + + public function testFilterRoutesEmployeeToEmployeeModel() + { + $decoded = ['id' => 1, 'emp_code' => 'EMP001']; + + $this->assertTrue(isset($decoded['emp_code']), 'Employee token should have emp_code'); + $id = $decoded['id'] ?? null; + $this->assertEquals(1, $id, 'Employee lookup should use id'); + } + + // ═══════════════════════════════════════════════ + // 7. Multi-Branch Token Generation + // ═══════════════════════════════════════════════ + + public function testEachBranchGetsUniqueToken() + { + $clientId = md5('42'); + $branches = [ + ['id' => 10, 'pre_hr_id' => 5, 'pre_client_id' => $clientId, 'pre_branch_id' => 1], + ['id' => 11, 'pre_hr_id' => 6, 'pre_client_id' => $clientId, 'pre_branch_id' => 2], + ['id' => 12, 'pre_hr_id' => 7, 'pre_client_id' => $clientId, 'pre_branch_id' => 3], + ]; + + $tokens = []; + foreach ($branches as $branch) { + $token = $this->createToken($branch); + $result = JWTToken::validateJWT("Bearer $token"); + + $this->assertTrue($result['status']); + $this->assertEquals($clientId, $result['decoded']['pre_client_id']); + $tokens[] = $token; + } + + $this->assertCount(3, array_unique($tokens), 'Each branch must produce a distinct token'); + } + + public function testBranchTokensDecodeToCorrectBranchId() + { + $branchIds = [1, 2, 3]; + + foreach ($branchIds as $i => $branchId) { + $payload = [ + 'id' => 10 + $i, + 'pre_hr_id' => 5 + $i, + 'pre_branch_id' => $branchId, + ]; + + $result = JWTToken::validateJWT($this->bearerHeader($payload)); + + $this->assertTrue($result['status']); + $this->assertEquals($branchId, $result['decoded']['pre_branch_id']); + } + } + + // ═══════════════════════════════════════════════ + // 8. Sliding Expiration Logic + // ═══════════════════════════════════════════════ + + public function testTokenTimeoutEnvIsReadable() + { + $this->assertEquals($this->tokenTimeout, (int)getenv('TOKENTIMEOUT')); + } + + public function testNewExpiryIsInTheFuture() + { + $before = time(); + $newExpiry = time() + (int)getenv('TOKENTIMEOUT'); + + $this->assertGreaterThanOrEqual($before + $this->tokenTimeout, $newExpiry); + } + + public function testActiveTokenPassesExpiryCheck() + { + $tokenTimeOut = time() + 600; + + $this->assertGreaterThan(time(), $tokenTimeOut, 'Active token_time_out must be in the future'); + } + + public function testExpiredTokenFailsExpiryCheck() + { + $tokenTimeOut = time() - 1; + + $this->assertLessThanOrEqual(time(), $tokenTimeOut, 'Expired token_time_out must be <= current time'); + } + + public function testNullExpiryFailsExpiryCheck() + { + $tokenTimeOut = null; + + $this->assertTrue($tokenTimeOut <= time(), 'null token_time_out should fail the expiry check'); + } + + // ═══════════════════════════════════════════════ + // 9. Multi-Branch Refresh Logic (the fix) + // ═══════════════════════════════════════════════ + + public function testBranchRefresh_UsesMobileWhenAvailable() + { + $user = ['mobile' => '9876543210', 'email' => 'hr@test.com']; + + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + + $this->assertEquals('mobile', $field); + $this->assertEquals('9876543210', $user[$field]); + } + + public function testBranchRefresh_FallsBackToEmailWhenMobileEmpty() + { + $user = ['mobile' => '', 'email' => 'hr@test.com']; + + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + + $this->assertEquals('email', $field); + $this->assertEquals('hr@test.com', $user[$field]); + } + + public function testBranchRefresh_FallsBackToEmailWhenMobileNull() + { + $user = ['mobile' => null, 'email' => 'hr@test.com']; + + $field = !empty($user['mobile']) ? 'mobile' : 'email'; + + $this->assertEquals('email', $field); + } + + public function testBranchRefresh_HRUserTakesMultiBranchPath() + { + $decoded = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + $isMultiBranch = !isset($decoded['emp_code']); + + $this->assertTrue($isMultiBranch, 'HR user (no emp_code) should take the multi-branch refresh path'); + } + + public function testBranchRefresh_EmployeeTakesSingleRowPath() + { + $decoded = ['id' => 1, 'emp_code' => 'EMP001']; + + $isSingleRow = isset($decoded['emp_code']); + + $this->assertTrue($isSingleRow, 'Employee (with emp_code) should take the single-row refresh path'); + } + + /** + * Simulates the branch-switching scenario to verify the refresh logic + * targets all branches, not just the active one. + */ + public function testBranchRefresh_SimulateMultiBranchExpiryUpdate() + { + $sameMobile = '9876543210'; + + // Three level_contacts rows for the same HR person across branches + $branchRows = [ + ['id' => 5, 'mobile' => $sameMobile, 'email' => 'hr@test.com', 'contact_type' => 'client', 'is_active' => 1, 'token_time_out' => time() + 1800], + ['id' => 6, 'mobile' => $sameMobile, 'email' => 'hr@test.com', 'contact_type' => 'client', 'is_active' => 1, 'token_time_out' => time() + 1800], + ['id' => 7, 'mobile' => $sameMobile, 'email' => 'hr@test.com', 'contact_type' => 'client', 'is_active' => 1, 'token_time_out' => time() + 1800], + ]; + + // User is on Branch A (id=5). AuthJWT decoded token says pre_hr_id = 5. + $currentUser = $branchRows[0]; + $decoded = ['id' => 10, 'pre_hr_id' => 5, 'pre_branch_id' => 1]; + + // The filter's multi-branch refresh logic: + $field = !empty($currentUser['mobile']) ? 'mobile' : 'email'; + $newExpiry = time() + $this->tokenTimeout; + + // Simulate: update all rows where $field matches (same as the WHERE clause in AuthJWT) + $updatedRows = array_filter($branchRows, function ($row) use ($field, $currentUser) { + return $row[$field] === $currentUser[$field] + && $row['contact_type'] === 'client' + && $row['is_active'] === 1; + }); + + foreach ($updatedRows as &$row) { + $row['token_time_out'] = $newExpiry; + } + unset($row); + + // ALL three branches must have the new expiry + $this->assertCount(3, $updatedRows, 'All three branch rows should be updated'); + foreach ($updatedRows as $row) { + $this->assertEquals($newExpiry, $row['token_time_out']); + } + } + + /** + * Contrast test: the old single-row logic would only update one branch. + */ + public function testBranchRefresh_OldLogicWouldLeaveOtherBranchesStale() + { + $loginTime = time(); + $timeout = $this->tokenTimeout; + $sameMobile = '9876543210'; + + $branchRows = [ + ['id' => 5, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 6, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 7, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ]; + + // Simulate 20 minutes of activity on Branch A only (old single-row logic) + $simulatedNow = $loginTime + 1200; // 20 min later + $branchRows[0]['token_time_out'] = $simulatedNow + $timeout; // only branch A refreshed + + // Branch B and C still have the original login-time expiry + $this->assertEquals($loginTime + $timeout, $branchRows[1]['token_time_out']); + $this->assertEquals($loginTime + $timeout, $branchRows[2]['token_time_out']); + + // After 31 minutes, Branch B and C would be expired + $switchTime = $loginTime + 1860; // 31 min later + $this->assertLessThanOrEqual($switchTime, $branchRows[1]['token_time_out'], + 'OLD logic: Branch B would have expired by the time user switches'); + $this->assertLessThanOrEqual($switchTime, $branchRows[2]['token_time_out'], + 'OLD logic: Branch C would have expired by the time user switches'); + + // But Branch A is still alive + $this->assertGreaterThan($switchTime, $branchRows[0]['token_time_out'], + 'Branch A stays alive because it was actively refreshed'); + } + + /** + * Proves the new logic keeps all branches alive. + */ + public function testBranchRefresh_NewLogicKeepsAllBranchesAlive() + { + $loginTime = time(); + $timeout = $this->tokenTimeout; + $sameMobile = '9876543210'; + + $branchRows = [ + ['id' => 5, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 6, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ['id' => 7, 'mobile' => $sameMobile, 'token_time_out' => $loginTime + $timeout], + ]; + + // Simulate 20 minutes of activity on Branch A (new multi-branch logic) + $simulatedNow = $loginTime + 1200; + $newExpiry = $simulatedNow + $timeout; + + // New logic refreshes ALL rows with same mobile + foreach ($branchRows as &$row) { + if ($row['mobile'] === $sameMobile) { + $row['token_time_out'] = $newExpiry; + } + } + unset($row); + + // After 31 minutes from login, ALL branches are still alive + $switchTime = $loginTime + 1860; + + foreach ($branchRows as $i => $row) { + $this->assertGreaterThan($switchTime, $row['token_time_out'], + "NEW logic: Branch id={$row['id']} should still be alive after switching"); + } + } + + // ═══════════════════════════════════════════════ + // 10. AuthJWT Filter — Rejection Tests + // ═══════════════════════════════════════════════ + + private function createFilter(): AuthJWT + { + // AuthJWT has require_once('../vendor/autoload.php') which resolves + // relative to CWD. In Apache CWD is public/, in PHPUnit it's the + // project root. Temporarily switch CWD so the require resolves. + $cwd = getcwd(); + chdir(PUBLICPATH); + try { + return new AuthJWT(); + } finally { + chdir($cwd); + } + } + + public function testFilter_RejectsMissingAuthHeader() + { + $filter = $this->createFilter(); + $request = $this->createMockRequest(''); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject requests without Authorization header'); + } + + public function testFilter_RejectsInvalidJWT() + { + $filter = $this->createFilter(); + $request = $this->createMockRequest('Bearer not.a.valid-jwt'); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject requests with an invalid JWT'); + } + + public function testFilter_RejectsTokenWithoutId() + { + $token = $this->createToken(['name' => 'no-id-field']); + $filter = $this->createFilter(); + $request = $this->createMockRequest("Bearer $token"); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject tokens without an id field'); + } + + public function testFilter_RejectsWrongAlgorithmToken() + { + $token = JWT::encode(['id' => 1], $this->jwtSecret, 'HS256'); + $filter = $this->createFilter(); + $request = $this->createMockRequest("Bearer $token"); + + $result = $filter->before($request); + + $this->assertNotTrue($result, 'Filter must reject tokens using non-HS512 algorithms'); + } +}