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