">
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');
+ }
+}