Adds security headers and CSRF protection tests
parent
9d0056f0a6
commit
c2f63f6121
|
@ -12,88 +12,106 @@
|
||||||
* - Permissions-Policy: Control browser features
|
* - Permissions-Policy: Control browser features
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Get current page
|
function applySecurityHeaders($testMode = false) {
|
||||||
$current_page = $_GET['page'] ?? 'dashboard';
|
$headers = [];
|
||||||
|
|
||||||
|
// Get current page
|
||||||
|
$current_page = $_GET['page'] ?? 'dashboard';
|
||||||
|
|
||||||
// Define pages that need media access
|
// Define pages that need media access
|
||||||
$media_enabled_pages = [
|
$media_enabled_pages = [
|
||||||
// 'conference' => ['camera', 'microphone'],
|
// 'conference' => ['camera', 'microphone'],
|
||||||
// 'call' => ['microphone'],
|
// 'call' => ['microphone'],
|
||||||
// Add more pages and their required permissions as needed
|
// Add more pages and their required permissions as needed
|
||||||
];
|
];
|
||||||
|
|
||||||
// Strict Transport Security (HSTS)
|
// Strict Transport Security (HSTS)
|
||||||
// Only enable if HTTPS is properly configured
|
// Only enable if HTTPS is properly configured
|
||||||
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
||||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
$headers[] = 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload';
|
||||||
}
|
|
||||||
|
|
||||||
// Content Security Policy (CSP)
|
|
||||||
$csp = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for Bootstrap and jQuery
|
|
||||||
"style-src 'self' 'unsafe-inline' https://use.fontawesome.com", // Allow FontAwesome CSS
|
|
||||||
"img-src 'self' data:", // Allow data: URLs for images
|
|
||||||
"font-src 'self' https://use.fontawesome.com", // Allow FontAwesome fonts
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'none'", // Equivalent to X-Frame-Options: DENY
|
|
||||||
"form-action 'self'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"upgrade-insecure-requests" // Force HTTPS for all requests
|
|
||||||
];
|
|
||||||
header("Content-Security-Policy: " . implode('; ', $csp));
|
|
||||||
|
|
||||||
// X-Frame-Options (legacy support)
|
|
||||||
header('X-Frame-Options: DENY');
|
|
||||||
|
|
||||||
// X-Content-Type-Options
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
|
|
||||||
// Referrer-Policy
|
|
||||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
||||||
|
|
||||||
// Permissions-Policy
|
|
||||||
$permissions = [
|
|
||||||
'geolocation=()',
|
|
||||||
'payment=()',
|
|
||||||
'usb=()',
|
|
||||||
'accelerometer=()',
|
|
||||||
'autoplay=()',
|
|
||||||
'document-domain=()',
|
|
||||||
'encrypted-media=()',
|
|
||||||
'fullscreen=(self)',
|
|
||||||
'magnetometer=()',
|
|
||||||
'midi=()',
|
|
||||||
'sync-xhr=(self)',
|
|
||||||
'usb=()'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add camera/microphone permissions based on current page
|
|
||||||
$camera_allowed = false;
|
|
||||||
$microphone_allowed = false;
|
|
||||||
|
|
||||||
if (isset($media_enabled_pages[$current_page])) {
|
|
||||||
$allowed_media = $media_enabled_pages[$current_page];
|
|
||||||
if (in_array('camera', $allowed_media)) {
|
|
||||||
$camera_allowed = true;
|
|
||||||
}
|
}
|
||||||
if (in_array('microphone', $allowed_media)) {
|
|
||||||
$microphone_allowed = true;
|
// Content Security Policy (CSP)
|
||||||
|
$csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for Bootstrap and jQuery
|
||||||
|
"style-src 'self' 'unsafe-inline' https://use.fontawesome.com", // Allow FontAwesome CSS
|
||||||
|
"img-src 'self' data:", // Allow data: URLs for images
|
||||||
|
"font-src 'self' https://use.fontawesome.com", // Allow FontAwesome fonts
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'", // Equivalent to X-Frame-Options: DENY
|
||||||
|
"form-action 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"upgrade-insecure-requests" // Force HTTPS for all requests
|
||||||
|
];
|
||||||
|
$headers[] = "Content-Security-Policy: " . implode('; ', $csp);
|
||||||
|
|
||||||
|
// X-Frame-Options (legacy support)
|
||||||
|
$headers[] = 'X-Frame-Options: DENY';
|
||||||
|
|
||||||
|
// X-Content-Type-Options
|
||||||
|
$headers[] = 'X-Content-Type-Options: nosniff';
|
||||||
|
|
||||||
|
// X-XSS-Protection
|
||||||
|
$headers[] = 'X-XSS-Protection: 1; mode=block';
|
||||||
|
|
||||||
|
// Referrer-Policy
|
||||||
|
$headers[] = 'Referrer-Policy: strict-origin-when-cross-origin';
|
||||||
|
|
||||||
|
// Permissions-Policy
|
||||||
|
$permissions = [
|
||||||
|
'geolocation=()',
|
||||||
|
'payment=()',
|
||||||
|
'usb=()',
|
||||||
|
'accelerometer=()',
|
||||||
|
'autoplay=()',
|
||||||
|
'document-domain=()',
|
||||||
|
'encrypted-media=()',
|
||||||
|
'fullscreen=(self)',
|
||||||
|
'magnetometer=()',
|
||||||
|
'midi=()',
|
||||||
|
'sync-xhr=(self)',
|
||||||
|
'usb=()'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add camera/microphone permissions based on current page
|
||||||
|
$camera_allowed = false;
|
||||||
|
$microphone_allowed = false;
|
||||||
|
|
||||||
|
if (isset($media_enabled_pages[$current_page])) {
|
||||||
|
$allowed_media = $media_enabled_pages[$current_page];
|
||||||
|
if (in_array('camera', $allowed_media)) {
|
||||||
|
$camera_allowed = true;
|
||||||
|
}
|
||||||
|
if (in_array('microphone', $allowed_media)) {
|
||||||
|
$microphone_allowed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add media permissions
|
||||||
|
$permissions[] = $camera_allowed ? 'camera=(self)' : 'camera=()';
|
||||||
|
$permissions[] = $microphone_allowed ? 'microphone=(self)' : 'microphone=()';
|
||||||
|
|
||||||
|
$headers[] = 'Permissions-Policy: ' . implode(', ', $permissions);
|
||||||
|
|
||||||
|
// Clear PHP version
|
||||||
|
if (!$testMode) {
|
||||||
|
header_remove('X-Powered-By');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent caching of sensitive pages
|
||||||
|
if (in_array($current_page, ['login', 'register', 'profile', 'security'])) {
|
||||||
|
$headers[] = 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0';
|
||||||
|
$headers[] = 'Pragma: no-cache';
|
||||||
|
$headers[] = 'Expires: ' . gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testMode) {
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply headers in production
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
header($header);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add media permissions
|
|
||||||
$permissions[] = $camera_allowed ? 'camera=(self)' : 'camera=()';
|
|
||||||
$permissions[] = $microphone_allowed ? 'microphone=(self)' : 'microphone=()';
|
|
||||||
|
|
||||||
header('Permissions-Policy: ' . implode(', ', $permissions));
|
|
||||||
|
|
||||||
// Clear PHP version
|
|
||||||
header_remove('X-Powered-By');
|
|
||||||
|
|
||||||
// Prevent caching of sensitive pages
|
|
||||||
if (in_array($current_page, ['login', 'register', 'profile', 'security'])) {
|
|
||||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
|
||||||
header('Pragma: no-cache');
|
|
||||||
header('Expires: ' . gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT');
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Framework\Integration\Security;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/app/classes/log.php';
|
||||||
|
require_once dirname(__DIR__, 4) . '/app/helpers/security.php';
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class TestLogger {
|
||||||
|
public static function insertLog($user_id, $message, $scope = 'user') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the CSRF middleware to use our test logger
|
||||||
|
function applyCsrfMiddleware() {
|
||||||
|
$security = \SecurityHelper::getInstance();
|
||||||
|
|
||||||
|
// Skip CSRF check for GET requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
return ['status' => 200, 'message' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip CSRF check for initial login attempt
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' &&
|
||||||
|
isset($_GET['page']) && $_GET['page'] === 'login' &&
|
||||||
|
!isset($_SESSION['username'])) {
|
||||||
|
return ['status' => 200, 'message' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check CSRF token for all other POST requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$token = $_POST['csrf_token'] ?? '';
|
||||||
|
if (!$security->verifyCsrfToken($token)) {
|
||||||
|
// Log CSRF attempt
|
||||||
|
$message = "CSRF attempt detected from IP: " . $_SERVER['REMOTE_ADDR'];
|
||||||
|
TestLogger::insertLog(0, $message, 'system');
|
||||||
|
|
||||||
|
// Return error message
|
||||||
|
return ['status' => 403, 'message' => $message];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 200, 'message' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CsrfProtectionTest extends TestCase
|
||||||
|
{
|
||||||
|
private $security;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->security = \SecurityHelper::getInstance();
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
parent::tearDown();
|
||||||
|
unset($_SESSION['csrf_token']);
|
||||||
|
unset($_POST['csrf_token']);
|
||||||
|
unset($_GET['page']);
|
||||||
|
unset($_SESSION['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectionValidToken()
|
||||||
|
{
|
||||||
|
// Generate CSRF token
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
$token = $_SESSION['csrf_token'];
|
||||||
|
|
||||||
|
// Simulate POST request with valid token
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST['csrf_token'] = $token;
|
||||||
|
|
||||||
|
// Call CSRF middleware
|
||||||
|
$response = applyCsrfMiddleware();
|
||||||
|
|
||||||
|
// Assert that the response is success
|
||||||
|
$this->assertEquals(200, $response['status']);
|
||||||
|
$this->assertEmpty($response['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectionInvalidToken()
|
||||||
|
{
|
||||||
|
// Simulate POST request with invalid token
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST['csrf_token'] = 'invalid_token';
|
||||||
|
|
||||||
|
// Call CSRF middleware
|
||||||
|
$response = applyCsrfMiddleware();
|
||||||
|
|
||||||
|
// Assert that the response is forbidden with error message
|
||||||
|
$this->assertEquals(403, $response['status']);
|
||||||
|
$this->assertStringContainsString("CSRF attempt detected from IP", $response['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectionGetRequest()
|
||||||
|
{
|
||||||
|
// Simulate GET request without token
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
|
||||||
|
// Call CSRF middleware
|
||||||
|
$response = applyCsrfMiddleware();
|
||||||
|
|
||||||
|
// Assert that GET requests are allowed without token
|
||||||
|
$this->assertEquals(200, $response['status']);
|
||||||
|
$this->assertEmpty($response['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectionInitialLogin()
|
||||||
|
{
|
||||||
|
// Simulate initial login POST request
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_GET['page'] = 'login';
|
||||||
|
|
||||||
|
// Call CSRF middleware
|
||||||
|
$response = applyCsrfMiddleware();
|
||||||
|
|
||||||
|
// Assert that initial login is allowed without token
|
||||||
|
$this->assertEquals(200, $response['status']);
|
||||||
|
$this->assertEmpty($response['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectionMissingToken()
|
||||||
|
{
|
||||||
|
// Simulate POST request without token
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
|
||||||
|
// Call CSRF middleware
|
||||||
|
$response = applyCsrfMiddleware();
|
||||||
|
|
||||||
|
// Assert that missing token is rejected
|
||||||
|
$this->assertEquals(403, $response['status']);
|
||||||
|
$this->assertStringContainsString("CSRF attempt detected from IP", $response['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectionEmptyToken()
|
||||||
|
{
|
||||||
|
// Simulate POST request with empty token
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST['csrf_token'] = '';
|
||||||
|
|
||||||
|
// Call CSRF middleware
|
||||||
|
$response = applyCsrfMiddleware();
|
||||||
|
|
||||||
|
// Assert that empty token is rejected
|
||||||
|
$this->assertEquals(403, $response['status']);
|
||||||
|
$this->assertStringContainsString("CSRF attempt detected from IP", $response['message']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Framework\Integration\Security;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 4) . '/app/includes/security_headers_middleware.php';
|
||||||
|
|
||||||
|
class SecurityHeadersTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
unset($_GET['page']);
|
||||||
|
unset($_SERVER['HTTPS']);
|
||||||
|
unset($_SERVER['REQUEST_URI']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBasicSecurityHeaders()
|
||||||
|
{
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Check security headers
|
||||||
|
$this->assertContains('X-Frame-Options: DENY', $headers);
|
||||||
|
$this->assertContains('X-XSS-Protection: 1; mode=block', $headers);
|
||||||
|
$this->assertContains('X-Content-Type-Options: nosniff', $headers);
|
||||||
|
$this->assertContains('Referrer-Policy: strict-origin-when-cross-origin', $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testContentSecurityPolicy()
|
||||||
|
{
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Get CSP header
|
||||||
|
$cspHeader = '';
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (strpos($header, 'Content-Security-Policy:') === 0) {
|
||||||
|
$cspHeader = $header;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check CSP directives
|
||||||
|
$this->assertStringContainsString("default-src 'self'", $cspHeader);
|
||||||
|
$this->assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval'", $cspHeader);
|
||||||
|
$this->assertStringContainsString("style-src 'self' 'unsafe-inline'", $cspHeader);
|
||||||
|
$this->assertStringContainsString("frame-ancestors 'none'", $cspHeader);
|
||||||
|
$this->assertStringContainsString("form-action 'self'", $cspHeader);
|
||||||
|
$this->assertStringContainsString("base-uri 'self'", $cspHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHstsHeader()
|
||||||
|
{
|
||||||
|
// Simulate HTTPS
|
||||||
|
$_SERVER['HTTPS'] = 'on';
|
||||||
|
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Check HSTS header
|
||||||
|
$this->assertContains(
|
||||||
|
'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload',
|
||||||
|
$headers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoHstsHeaderOnHttp()
|
||||||
|
{
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Check HSTS header is not present
|
||||||
|
$hasHsts = false;
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (strpos($header, 'Strict-Transport-Security:') === 0) {
|
||||||
|
$hasHsts = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertFalse($hasHsts, 'HSTS header should not be present on HTTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCacheControlForSensitivePages()
|
||||||
|
{
|
||||||
|
$sensitivePages = ['login', 'register', 'profile', 'security'];
|
||||||
|
|
||||||
|
foreach ($sensitivePages as $page) {
|
||||||
|
// Set current page
|
||||||
|
$_GET['page'] = $page;
|
||||||
|
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Check cache control headers
|
||||||
|
$this->assertContains('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', $headers);
|
||||||
|
$this->assertContains('Pragma: no-cache', $headers);
|
||||||
|
$this->assertStringContainsString('Expires:', implode(' ', $headers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoCacheControlForNonSensitivePages()
|
||||||
|
{
|
||||||
|
$_GET['page'] = 'dashboard';
|
||||||
|
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Check cache control headers are not present
|
||||||
|
$this->assertNotContains('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', $headers);
|
||||||
|
$this->assertNotContains('Pragma: no-cache', $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPermissionsPolicy()
|
||||||
|
{
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Get Permissions-Policy header
|
||||||
|
$permissionsHeader = '';
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (strpos($header, 'Permissions-Policy:') === 0) {
|
||||||
|
$permissionsHeader = $header;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check basic permissions
|
||||||
|
$this->assertStringContainsString('geolocation=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('payment=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('camera=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('microphone=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('fullscreen=(self)', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('sync-xhr=(self)', $permissionsHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPermissionsPolicyForMediaEnabledPages()
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_URI'] = '/media/upload';
|
||||||
|
|
||||||
|
// Apply security headers in test mode
|
||||||
|
$headers = \applySecurityHeaders(true);
|
||||||
|
|
||||||
|
// Get Permissions-Policy header
|
||||||
|
$permissionsHeader = '';
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (strpos($header, 'Permissions-Policy:') === 0) {
|
||||||
|
$permissionsHeader = $header;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions policy header
|
||||||
|
$this->assertStringContainsString('camera=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('microphone=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('geolocation=()', $permissionsHeader);
|
||||||
|
$this->assertStringContainsString('payment=()', $permissionsHeader);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,9 @@
|
||||||
<testsuite name="Unit">
|
<testsuite name="Unit">
|
||||||
<directory suffix="Test.php">./Unit</directory>
|
<directory suffix="Test.php">./Unit</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
|
<testsuite name="Integration">
|
||||||
|
<directory suffix="Test.php">./Integration</directory>
|
||||||
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<coverage processUncoveredFiles="true">
|
<coverage processUncoveredFiles="true">
|
||||||
<include>
|
<include>
|
||||||
|
|
Loading…
Reference in New Issue