diff --git a/public/index.php b/public/index.php index 81dc4f7..ff3e3fa 100644 --- a/public/index.php +++ b/public/index.php @@ -698,14 +698,21 @@ if ($route === 'reports') { $format = scalar_input($_GET['format'] ?? null); if ($format === 'csv') require_permission('reports.export'); try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); } - $reportParams = ['client_id' => $filters->clientId ?? 0, 'status' => $filters->status ?? '', 'status_filter' => $filters->status ?? '', 'priority' => $filters->priority ?? '', 'priority_filter' => $filters->priority ?? '', 'date_from_a' => $filters->dateFrom ?? '', 'date_from_b' => $filters->dateFrom ?? '', 'date_to_a' => $filters->dateTo ?? '', 'date_to_b' => $filters->dateTo ?? '']; - $reportScope = $user['role_name'] === 'Technician' ? 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned' : ''; - $reportParams['user_assigned'] = $user['id']; - $hoursCondition = $user['role_name'] === 'Technician' ? 'te.technician_id = :user' : '1 = 1'; - $reportParams['user'] = $user['id']; - $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . $hoursCondition . ' AND (:date_from_a = "" OR te.work_date >= :date_from_b) AND (:date_to_a = "" OR te.work_date <= :date_to_b) THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id ' . $reportScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided") WHERE (:client_id = 0 OR c.id = :client_filter) AND (:status = "" OR j.status = :status_filter) AND (:priority = "" OR j.priority = :priority_filter) GROUP BY c.id, c.name ORDER BY c.name'); - $reportParams['client_filter'] = $filters->clientId ?? 0; - $reportStmt->execute($reportParams); + $where = []; + $params = []; + if ($filters->clientId !== null) { $where[] = 'c.id = ?'; $params[] = $filters->clientId; } + if ($filters->status !== null) { $where[] = 'j.status = ?'; $params[] = $filters->status; } + if ($filters->priority !== null) { $where[] = 'j.priority = ?'; $params[] = $filters->priority; } + $reportScope = ''; + if ($user['role_name'] === 'Technician') { $reportScope = 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = ?'; $scopeParam = (int)$user['id']; } else { $scopeParam = null; } + $hoursPredicates = ['NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided")']; + $hoursParams = []; + if ($user['role_name'] === 'Technician') { array_unshift($hoursPredicates, 'te.technician_id = ?'); $hoursParams[] = (int)$user['id']; } + if ($filters->dateFrom !== null) { $hoursPredicates[] = 'te.work_date >= ?'; $hoursParams[] = $filters->dateFrom; } + if ($filters->dateTo !== null) { $hoursPredicates[] = 'te.work_date <= ?'; $hoursParams[] = $filters->dateTo; } + $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . implode(' AND ', $hoursPredicates) . ' THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id ' . $reportScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id' . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' GROUP BY c.id, c.name ORDER BY c.name'); + $params = array_merge($hoursParams, $scopeParam === null ? [] : [$scopeParam], $params); + $reportStmt->execute($params); $reportRows = $reportStmt->fetchAll(); $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows); $filterQuery = http_build_query(array_filter(['client_id' => $filters->clientId, 'date_from' => $filters->dateFrom, 'date_to' => $filters->dateTo, 'technician_id' => $filters->technicianId, 'status' => $filters->status, 'priority' => $filters->priority], static fn($value): bool => $value !== null && $value !== '')); diff --git a/tests/SecurityRegressionTest.php b/tests/SecurityRegressionTest.php index 701a9b0..b0ea9dc 100644 --- a/tests/SecurityRegressionTest.php +++ b/tests/SecurityRegressionTest.php @@ -51,7 +51,7 @@ $checks++; // The technician report route must sum only the logged-in technician's entries, // even when an assigned jobcard contains entries recorded by other technicians. security_regression_assert( - str_contains($frontController, "\$hoursCondition = \$user['role_name'] === 'Technician'") && str_contains($frontController, 'te.technician_id = :user'), + str_contains($frontController, 'array_unshift($hoursPredicates') && str_contains($frontController, 'te.technician_id = ?'), 'Technician report SQL must isolate hours to the authenticated technician.' ); $activity = (new TechnicianActivityReport())->build([