systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php'; require_once SYSTEMPATH . 'Config/DotEnv.php'; (new CodeIgniter\Config\DotEnv(ROOTPATH))->load(); defined('ENVIRONMENT') || define('ENVIRONMENT', env('CI_ENVIRONMENT', 'development')); $boot = APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php'; if (is_file($boot)) { require_once $boot; } // Smoke encode/decode needs a secret; use a local fallback if JWT_SECRET is unset. if (empty(env('JWT_SECRET'))) { $smokeJwtSecret = 'smoke-claims-search-jwt-secret'; putenv('JWT_SECRET=' . $smokeJwtSecret); $_ENV['JWT_SECRET'] = $smokeJwtSecret; $_SERVER['JWT_SECRET'] = $smokeJwtSecret; } use App\Controllers\EmployeeRestController; use App\Helpers\JWTToken; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use Config\Services; use Firebase\JWT\JWT; $pass = 0; $fail = 0; $skip = 0; $results = []; $notes = []; function ok(string $label, bool $cond, string $detail = ''): void { global $pass, $fail, $results; if ($cond) { $pass++; $results[] = '[PASS] ' . $label . ($detail !== '' ? " — {$detail}" : ''); } else { $fail++; $results[] = '[FAIL] ' . $label . ($detail !== '' ? " — {$detail}" : ''); } } function skip(string $label, string $detail = ''): void { global $skip, $results; $skip++; $results[] = '[SKIP] ' . $label . ($detail !== '' ? " — {$detail}" : ''); } function note(string $msg): void { global $notes; $notes[] = $msg; } /** * Build a HS512 JWT with allowed_active_policies (does not touch DB). * * @param list|string|null $activePolicyIds */ function makeHrJwt($activePolicyIds): string { $claim = is_array($activePolicyIds) ? json_encode(array_values($activePolicyIds)) : $activePolicyIds; $payload = [ 'id' => 1, 'post_hr_id' => 1, 'allowed_active_policies' => $claim, ]; return JWT::encode($payload, (string) env('JWT_SECRET'), 'HS512'); } /** * Invoke claimsSearch with GET query or POST JSON body. * * @param list|string|null|false $tokenPolicyIds false = no Authorization header * @return array{http:int,body:array,raw:string,error:?string} */ function invokeClaimsSearch(string $method, array $payload = [], $tokenPolicyIds = false): array { try { $uri = new URI('http://localhost/nhance_v2/claimsSearch'); $bodyString = null; if (strtoupper($method) === 'GET') { if ($payload !== []) { $uri->setQuery(http_build_query($payload)); } } else { $bodyString = json_encode($payload, JSON_THROW_ON_ERROR); } $request = new IncomingRequest( config('App'), $uri, $bodyString, new UserAgent() ); $request->setMethod(strtolower($method)); if (strtoupper($method) === 'GET') { $request->setGlobal('get', $payload); } else { $request->setHeader('Content-Type', 'application/json'); $request->setGlobal('post', []); } if ($tokenPolicyIds !== false) { $request->setHeader('Authorization', 'Bearer ' . makeHrJwt($tokenPolicyIds)); } // Fresh controller per call — TicketController caches claimType in ctor $controller = new EmployeeRestController(); $response = Services::response(); $logger = Services::logger(); $controller->initController($request, $response, $logger); $resp = $controller->claimsSearch(); $raw = $resp->getBody(); $body = json_decode($raw, true); return [ 'http' => $resp->getStatusCode(), 'body' => is_array($body) ? $body : [], 'raw' => (string) $raw, 'error' => null, ]; } catch (Throwable $e) { return [ 'http' => 500, 'body' => [], 'raw' => '', 'error' => $e->getMessage(), ]; } } function ticketTypeIds(array $ticketTypes): array { return array_map('strval', array_column($ticketTypes, 'ticket_type')); } // ─── Fixtures from live DB ─────────────────────────────────────────────────── $db = db_connect(); $cliClientId = isset($argv[1]) && ctype_digit((string) $argv[1]) ? (int) $argv[1] : null; $sampleTicket = $db->query( "SELECT tm.id, tm.client_id, tm.client_policy_id, tm.claim_number, tm.claim_status_id, tm.ticket_type_id, tm.emp_code, tm.emp_name, tm.created_at, tm.is_active, cp.policy_no FROM ticket_master tm LEFT JOIN client_policy cp ON cp.id = tm.client_policy_id WHERE tm.is_active = 1 AND tm.client_id IS NOT NULL ORDER BY tm.id DESC LIMIT 1" )->getRowArray(); if ($cliClientId !== null) { $override = $db->query( "SELECT tm.id, tm.client_id, tm.client_policy_id, tm.claim_number, tm.claim_status_id, tm.ticket_type_id, tm.emp_code, tm.emp_name, tm.created_at, tm.is_active, cp.policy_no FROM ticket_master tm LEFT JOIN client_policy cp ON cp.id = tm.client_policy_id WHERE tm.is_active = 1 AND tm.client_id = ? ORDER BY tm.id DESC LIMIT 1", [$cliClientId] )->getRowArray(); if ($override) { $sampleTicket = $override; } } ok('Fixture: active ticket available', !empty($sampleTicket['id']), json_encode($sampleTicket ?: [])); $clientId = (int) ($sampleTicket['client_id'] ?? 0); $clientMd5 = $clientId > 0 ? md5((string) $clientId) : ''; $policyNo = $sampleTicket['policy_no'] ?? null; $policyId = $sampleTicket['client_policy_id'] ?? null; $claimStatusId = $sampleTicket['claim_status_id'] ?? null; $ticketTypeId = $sampleTicket['ticket_type_id'] ?? null; $empCode = $sampleTicket['emp_code'] ?? null; $createdAt = $sampleTicket['created_at'] ?? null; $policyTypes = $clientId > 0 ? $db->query( 'SELECT DISTINCT policy_type_id FROM client_policy WHERE client_id = ? AND is_active = 1', [$clientId] )->getResultArray() : []; $policyTypeIds = array_map('intval', array_column($policyTypes, 'policy_type_id')); $opdClient = $db->query( 'SELECT client_id FROM client_policy WHERE policy_type_id = 72 AND is_active = 1 LIMIT 1' )->getRowArray(); $statusRow = $db->query( "SELECT id, display_name FROM ticket_claim_status WHERE is_active = 1 AND display_name IS NOT NULL AND display_name <> '' LIMIT 1" )->getRowArray(); $sameDisplayIds = []; if (!empty($statusRow['display_name'])) { $sameDisplayIds = array_map( 'intval', array_column( $db->query( 'SELECT id FROM ticket_claim_status WHERE is_active = 1 AND display_name = ?', [$statusRow['display_name']] )->getResultArray(), 'id' ) ); } $inactiveTicket = $db->query( 'SELECT id, client_id FROM ticket_master WHERE is_active = 0 AND client_id IS NOT NULL LIMIT 1' )->getRowArray(); $expectedRowKeys = [ 'id', 'ticket_type_id', 'original_status', 'status', 'cl_type', 'claim_no', 'claim_status_id', 'emp_name', 'emp_code', 'client_name', 'ticket_created_date', 'policy_no', 'policy_type', 'client_policy_no', ]; echo "========== claimsSearch DEEP SMOKE ==========\n"; echo 'client_id=' . $clientId . ' md5=' . $clientMd5 . "\n"; echo 'sample_ticket_id=' . ($sampleTicket['id'] ?? 'n/a') . "\n"; echo 'policy_types=' . json_encode($policyTypeIds) . "\n\n"; // ═══════════════════════════════════════════════════════════════════════════ // GET — filter masters // ═══════════════════════════════════════════════════════════════════════════ $getNoClient = invokeClaimsSearch('GET'); $d = $getNoClient['body']['data'] ?? []; ok( 'GET no client_id: envelope success/200', ($getNoClient['body']['status'] ?? '') === 'success' && (int) ($getNoClient['body']['code'] ?? 0) === 200, json_encode(['status' => $getNoClient['body']['status'] ?? null, 'code' => $getNoClient['body']['code'] ?? null]) ); ok('GET no client_id: has claim_status', isset($d['claim_status']) && is_array($d['claim_status']) && count($d['claim_status']) > 0, 'count=' . count($d['claim_status'] ?? [])); ok('GET no client_id: has claim_type', isset($d['claim_type']) && is_array($d['claim_type']), 'keys=' . json_encode(array_keys($d['claim_type'] ?? []))); ok( 'GET no client_id: default ticket_type = GMC/GPA/EDLI/GTLI', ticketTypeIds($d['ticket_type'] ?? []) === ['1', '2', '3', '4'], json_encode($d['ticket_type'] ?? []) ); ok( 'GET no client_id: default ticket_type excludes OPD (72)', !in_array('72', ticketTypeIds($d['ticket_type'] ?? []), true), json_encode(ticketTypeIds($d['ticket_type'] ?? [])) ); $statusSample = $d['claim_status'][0] ?? []; ok( 'GET claim_status rows expose id/ticket_type/claim_status', isset($statusSample['id'], $statusSample['ticket_type'], $statusSample['claim_status']), json_encode($statusSample) ); $claimType = $d['claim_type'] ?? []; ok( 'GET claim_type structure (type 1 + 2)', isset($claimType[1][1], $claimType[2][1]) && $claimType[1][1] === 'Main Hospitalization' && $claimType[2][1] === 'TTD', json_encode($claimType) ); // Numeric client_id — expected ticket types from policy_type_id mapping if ($clientId > 0) { $getClient = invokeClaimsSearch('GET', ['client_id' => $clientId]); $types = ticketTypeIds($getClient['body']['data']['ticket_type'] ?? []); $expect = []; if (array_intersect($policyTypeIds, [2, 3, 4, 5])) { $expect[] = '1'; // Claim-GMC } if (in_array(1, $policyTypeIds, true)) { $expect[] = '2'; // Claim-GPA } if (in_array(6, $policyTypeIds, true)) { $expect[] = '3'; // EDLI } if (in_array(7, $policyTypeIds, true)) { $expect[] = '4'; // GTLI } if (in_array(72, $policyTypeIds, true)) { $expect[] = '72'; // OPD } sort($expect, SORT_NUMERIC); $got = $types; sort($got, SORT_NUMERIC); ok( 'GET numeric client_id: ticket_type mapped from active policies', $got === $expect, 'expected=' . json_encode($expect) . ' got=' . json_encode($types) . ' policy_types=' . json_encode($policyTypeIds) ); ok( 'GET numeric client_id: ticket_type sorted ascending', $types === $got, json_encode($types) ); $getMd5 = invokeClaimsSearch('GET', ['client_id' => $clientMd5]); ok( 'GET MD5 client_id resolves same ticket_type as numeric', ticketTypeIds($getMd5['body']['data']['ticket_type'] ?? []) === $types, 'md5_types=' . json_encode(ticketTypeIds($getMd5['body']['data']['ticket_type'] ?? [])) ); } else { skip('GET numeric/MD5 client_id mapping', 'no sample client'); } if (!empty($opdClient['client_id'])) { $getOpd = invokeClaimsSearch('GET', ['client_id' => (int) $opdClient['client_id']]); ok( 'GET OPD client includes ticket_type 72', in_array('72', ticketTypeIds($getOpd['body']['data']['ticket_type'] ?? []), true), json_encode($getOpd['body']['data']['ticket_type'] ?? []) ); } else { skip('GET OPD ticket_type', 'no active OPD policy_type_id=72 client'); } $getBogusMd5 = invokeClaimsSearch('GET', ['client_id' => str_repeat('a', 32)]); ok( 'GET unknown MD5 client_id: still success envelope with empty/partial ticket_type', ($getBogusMd5['body']['status'] ?? '') === 'success' && isset($getBogusMd5['body']['data']['ticket_type']), json_encode([ 'status' => $getBogusMd5['body']['status'] ?? null, 'ticket_type' => $getBogusMd5['body']['data']['ticket_type'] ?? null, ]) ); // ═══════════════════════════════════════════════════════════════════════════ // POST — search // ═══════════════════════════════════════════════════════════════════════════ $postNoToken = invokeClaimsSearch('POST', ['client_id' => $clientId ?: 1], false); ok( 'POST without JWT token: failed/404 empty', $postNoToken['http'] === 200 && ($postNoToken['body']['status'] ?? '') === 'failed' && (int) ($postNoToken['body']['code'] ?? 0) === 404 && ($postNoToken['body']['data'] ?? null) === [], json_encode($postNoToken['body']) ); $postEmptyClaim = invokeClaimsSearch('POST', ['client_id' => $clientId ?: 1], []); ok( 'POST JWT allowed_active_policies=[] → failed/404 empty', ($postEmptyClaim['body']['status'] ?? '') === 'failed' && (int) ($postEmptyClaim['body']['code'] ?? 0) === 404 && ($postEmptyClaim['body']['data'] ?? null) === [], json_encode($postEmptyClaim['body']) ); $postNullClaim = invokeClaimsSearch('POST', ['client_id' => $clientId ?: 1], null); ok( 'POST JWT allowed_active_policies=null → failed/404 empty', ($postNullClaim['body']['status'] ?? '') === 'failed' && (int) ($postNullClaim['body']['code'] ?? 0) === 404 && ($postNullClaim['body']['data'] ?? null) === [], json_encode($postNullClaim['body']) ); // Helper can parse JSON-string claim shape used in HR tokens $helperJwt = makeHrJwt([(int) ($policyId ?: 1)]); $helperReq = new IncomingRequest( config('App'), new URI('http://localhost/nhance_v2/claimsSearch'), null, new UserAgent() ); $helperReq->setHeader('Authorization', 'Bearer ' . $helperJwt); ok( 'JWTToken::getValueFromToken(request, key) parses JSON claim', JWTToken::getValueFromToken($helperReq, 'allowed_active_policies', []) === [(int) ($policyId ?: 1)], json_encode(JWTToken::getValueFromToken($helperReq, 'allowed_active_policies', [])) ); ok( 'JWTToken::getValueFromToken returns default for missing key', JWTToken::getValueFromToken($helperReq, 'not_a_real_claim', 'missing') === 'missing', (string) JWTToken::getValueFromToken($helperReq, 'not_a_real_claim', 'missing') ); if ($clientId > 0 && !empty($policyId)) { $tokenPolicies = [(int) $policyId]; $basePost = ['client_id' => $clientId]; $byClient = invokeClaimsSearch('POST', $basePost, $tokenPolicies); $rows = $byClient['body']['data'] ?? []; $allMatch = is_array($rows) && count($rows) > 0; if ($allMatch) { $ids = array_column($rows, 'id'); $mismatch = $db->query( 'SELECT COUNT(*) AS c FROM ticket_master WHERE id IN (' . implode(',', array_map('intval', $ids)) . ') AND client_id <> ?', [$clientId] )->getRowArray(); ok( 'POST client_id: all returned tickets belong to client', (int) ($mismatch['c'] ?? 1) === 0, 'rows=' . count($rows) . ' mismatches=' . ($mismatch['c'] ?? '?') ); ok( 'POST client_id: includes fixture ticket', in_array((string) $sampleTicket['id'], array_map('strval', $ids), true) || in_array((int) $sampleTicket['id'], array_map('intval', $ids), true), 'fixture_id=' . $sampleTicket['id'] ); $missingKeys = []; foreach ($expectedRowKeys as $k) { if (!array_key_exists($k, $rows[0])) { $missingKeys[] = $k; } } ok('POST result row has expected keys', $missingKeys === [], 'missing=' . json_encode($missingKeys)); ok( 'POST status is UPPER display/fallback', is_string($rows[0]['status'] ?? null) && ($rows[0]['status'] === '' || $rows[0]['status'] === strtoupper($rows[0]['status'])), 'status=' . ($rows[0]['status'] ?? 'null') ); ok( 'POST cl_type present (Reimbursement fallback or tpa_claim_type)', isset($rows[0]['cl_type']) && $rows[0]['cl_type'] !== '', 'cl_type=' . ($rows[0]['cl_type'] ?? 'null') ); ok( 'POST with active_policy_ids: ordered by id DESC', count($rows) < 2 || (int) $rows[0]['id'] >= (int) $rows[1]['id'], 'first=' . ($rows[0]['id'] ?? '?') . ' second=' . ($rows[1]['id'] ?? 'n/a') ); } else { ok('POST client_id: returns data for known client', false, json_encode($byClient['body'])); } $byMd5 = invokeClaimsSearch('POST', [ 'client_id' => $clientMd5, ], $tokenPolicies); ok( 'POST MD5 client_id: same row count as numeric', count($byMd5['body']['data'] ?? []) === count($rows), 'numeric=' . count($rows) . ' md5=' . count($byMd5['body']['data'] ?? []) ); // Body active_policy_ids must NOT widen access beyond token $bodyWiden = invokeClaimsSearch('POST', [ 'client_id' => $clientId, 'active_policy_ids' => [(int) $policyId, 999999999], ], $tokenPolicies); ok( 'POST ignores body active_policy_ids (token is source of truth)', count($bodyWiden['body']['data'] ?? []) === count($rows), 'token_only=' . count($rows) . ' with_body_extra=' . count($bodyWiden['body']['data'] ?? []) ); // Date range — day of fixture created_at if (!empty($createdAt)) { $day = date('d-m-Y', strtotime($createdAt)); $byDate = invokeClaimsSearch('POST', $basePost + [ 'from_date' => $day, 'to_date' => $day, ], $tokenPolicies); $dateRows = $byDate['body']['data'] ?? []; $idsOnDay = array_map('strval', array_column($dateRows, 'id')); ok( 'POST from_date+to_date includes fixture on that day', in_array((string) $sampleTicket['id'], $idsOnDay, true), 'day=' . $day . ' count=' . count($dateRows) ); $onlyFrom = invokeClaimsSearch('POST', $basePost + [ 'from_date' => $day, ], $tokenPolicies); // Code requires BOTH from_date AND to_date — partial should not apply date filter ok( 'POST from_date alone does NOT restrict by date (needs both)', count($onlyFrom['body']['data'] ?? []) >= count($dateRows), 'with_both=' . count($dateRows) . ' from_only=' . count($onlyFrom['body']['data'] ?? []) ); $farPast = invokeClaimsSearch('POST', $basePost + [ 'from_date' => '01-01-2000', 'to_date' => '02-01-2000', ], $tokenPolicies); ok( 'POST date range with no tickets → failed/404 empty', ($farPast['body']['status'] ?? '') === 'failed' && (int) ($farPast['body']['code'] ?? 0) === 404 && ($farPast['body']['data'] ?? null) === [], json_encode([ 'status' => $farPast['body']['status'] ?? null, 'code' => $farPast['body']['code'] ?? null, 'data' => $farPast['body']['data'] ?? null, ]) ); } if (!empty($ticketTypeId)) { $byType = invokeClaimsSearch('POST', $basePost + [ 'ticket_type_id' => $ticketTypeId, ], $tokenPolicies); $typeRows = $byType['body']['data'] ?? []; $badType = 0; foreach ($typeRows as $r) { if ((string) ($r['ticket_type_id'] ?? '') !== (string) $ticketTypeId) { $badType++; } } ok( 'POST dynamic where ticket_type_id filters correctly', count($typeRows) > 0 && $badType === 0, 'ticket_type_id=' . $ticketTypeId . ' rows=' . count($typeRows) . ' bad=' . $badType ); } if (!empty($empCode)) { $byEmp = invokeClaimsSearch('POST', $basePost + [ 'emp_code' => $empCode, ], $tokenPolicies); $empRows = $byEmp['body']['data'] ?? []; $badEmp = 0; foreach ($empRows as $r) { if ((string) ($r['emp_code'] ?? '') !== (string) $empCode) { $badEmp++; } } ok( 'POST dynamic where emp_code filters correctly', count($empRows) > 0 && $badEmp === 0, 'emp_code=' . $empCode . ' rows=' . count($empRows) . ' bad=' . $badEmp ); } // Empty / zero values must be ignored in dynamic where $ignoreZero = invokeClaimsSearch('POST', $basePost + [ 'ticket_type_id' => 0, 'claim_type' => '', 'priority' => '0', ], $tokenPolicies); ok( 'POST empty/0 dynamic fields ignored (count ≈ client-only)', count($ignoreZero['body']['data'] ?? []) === count($rows), 'client_only=' . count($rows) . ' with_empties=' . count($ignoreZero['body']['data'] ?? []) ); // client_branch_id is explicitly unset — must not break search $withBranch = invokeClaimsSearch('POST', $basePost + [ 'client_branch_id' => 999999, ], $tokenPolicies); ok( 'POST client_branch_id is ignored (unset before where)', count($withBranch['body']['data'] ?? []) === count($rows), 'without=' . count($rows) . ' with_branch=' . count($withBranch['body']['data'] ?? []) ); if (!empty($policyNo)) { $byPolicy = invokeClaimsSearch('POST', $basePost + [ 'policy_no' => $policyNo, ], $tokenPolicies); $policyRows = $byPolicy['body']['data'] ?? []; $badPol = 0; foreach ($policyRows as $r) { if (($r['client_policy_no'] ?? null) !== null && (string) $r['client_policy_no'] !== (string) $policyNo && (string) ($r['policy_no'] ?? '') !== (string) $policyNo ) { if ((string) ($r['client_policy_no'] ?? '') !== (string) $policyNo) { $badPol++; } } } ok( 'POST policy_no filters via client_policy.policy_no', count($policyRows) > 0 && $badPol === 0, 'policy_no=' . $policyNo . ' rows=' . count($policyRows) . ' bad=' . $badPol ); } else { skip('POST policy_no filter', 'fixture has no policy_no'); } $byActive = invokeClaimsSearch('POST', $basePost, $tokenPolicies); ok( 'POST token active_policy_ids does not fatal', $byActive['error'] === null, $byActive['error'] ?? 'ok' ); $activeRows = $byActive['body']['data'] ?? []; $badActive = 0; foreach ($activeRows as $r) { if ((string) ($r['client_policy_id'] ?? '') !== (string) $policyId) { $badActive++; } } ok( 'POST token allowed_active_policies restricts to those client_policy ids', $byActive['error'] === null && count($activeRows) > 0 && $badActive === 0, 'policy_id=' . $policyId . ' rows=' . count($activeRows) . ' bad=' . $badActive ); $byMulti = invokeClaimsSearch('POST', $basePost, [(int) $policyId, 0]); ok( 'POST token with zero id still returns fixture policy rows', $byMulti['error'] === null && count($byMulti['body']['data'] ?? []) >= count($activeRows), 'single=' . count($activeRows) . ' multi=' . count($byMulti['body']['data'] ?? []) ); if (!empty($claimStatusId) || !empty($statusRow['id'])) { $statusFilterId = (int) ($statusRow['id'] ?? $claimStatusId); $byStatus = invokeClaimsSearch('POST', $basePost + [ 'claim_status_id' => $statusFilterId, ], $tokenPolicies); $statusRows = $byStatus['body']['data'] ?? []; $allowed = $sameDisplayIds ?: [(int) $statusFilterId]; $badSt = 0; foreach ($statusRows as $r) { if (!in_array((int) ($r['claim_status_id'] ?? 0), $allowed, true)) { $badSt++; } } ok( 'POST claim_status_id expands via display_name helper', $badSt === 0, 'filter_id=' . $statusFilterId . ' allowed=' . json_encode($allowed) . ' rows=' . count($statusRows) . ' bad=' . $badSt . ' display=' . ($statusRow['display_name'] ?? '') ); $ctrl = new EmployeeRestController(); $expanded = $ctrl->getTicketClaimStatusIdBasedOnTheDisplayName($statusFilterId); sort($expanded); $expectedExpanded = $allowed; sort($expectedExpanded); ok( 'Helper getTicketClaimStatusIdBasedOnTheDisplayName matches DB', array_map('intval', $expanded) === array_map('intval', $expectedExpanded), 'helper=' . json_encode($expanded) . ' db=' . json_encode($expectedExpanded) ); } // Combined filters should be AND if (!empty($ticketTypeId) && !empty($createdAt)) { $day = date('d-m-Y', strtotime($createdAt)); $combined = invokeClaimsSearch('POST', $basePost + [ 'ticket_type_id' => $ticketTypeId, 'from_date' => $day, 'to_date' => $day, 'emp_code' => $empCode, ], $tokenPolicies); $cRows = $combined['body']['data'] ?? []; $okCombined = true; foreach ($cRows as $r) { if ((string) ($r['ticket_type_id'] ?? '') !== (string) $ticketTypeId) { $okCombined = false; } if ($empCode && (string) ($r['emp_code'] ?? '') !== (string) $empCode) { $okCombined = false; } } ok( 'POST combined filters use AND semantics', $okCombined && ( in_array((string) $sampleTicket['id'], array_map('strval', array_column($cRows, 'id')), true) || count($cRows) === 0 ), 'rows=' . count($cRows) . ' fixture_in=' . (in_array((string) $sampleTicket['id'], array_map('strval', array_column($cRows, 'id')), true) ? 'yes' : 'no') ); } // Unknown client → empty failed $unknown = invokeClaimsSearch('POST', [ 'client_id' => 999999999, ], $tokenPolicies); ok( 'POST unknown client_id → failed/404 empty data', ($unknown['body']['status'] ?? '') === 'failed' && (int) ($unknown['body']['code'] ?? 0) === 404 && ($unknown['body']['data'] ?? null) === [], json_encode($unknown['body']) ); // Inactive tickets must never appear if (!empty($inactiveTicket['id'])) { $huntInactive = invokeClaimsSearch('POST', [ 'client_id' => (int) $inactiveTicket['client_id'], ], $tokenPolicies); $inactiveFound = false; foreach ($huntInactive['body']['data'] ?? [] as $r) { if ((int) ($r['id'] ?? 0) === (int) $inactiveTicket['id']) { $inactiveFound = true; break; } } ok( 'POST excludes is_active=0 tickets', !$inactiveFound, 'inactive_id=' . $inactiveTicket['id'] ); } else { skip('POST inactive exclusion', 'no inactive ticket fixture'); } // SQL-ish junk in dates — should not fatal $inj = invokeClaimsSearch('POST', $basePost + [ 'from_date' => "01-01-2020' OR '1'='1", 'to_date' => '31-12-2020', ], $tokenPolicies); ok( 'POST crafted from_date does not fatal', isset($inj['body']['status']) || $inj['error'] !== null, 'status=' . ($inj['body']['status'] ?? 'null') . ' http=' . $inj['http'] . ' error=' . ($inj['error'] ?? 'null') ); if (($inj['body']['status'] ?? '') === 'success' && count($inj['body']['data'] ?? []) > 0) { note('SECURITY: date filter is string-interpolated; crafted from_date still returned rows — review SQL injection risk'); } // Dynamic where column injection attempt — unknown keys become tm. $bogusCol = invokeClaimsSearch('POST', $basePost + [ 'definitely_not_a_col' => 'x', ], $tokenPolicies); ok( 'POST unknown dynamic field handled without uncaught fatal', true, 'error=' . ($bogusCol['error'] ?? 'null') . ' status=' . ($bogusCol['body']['status'] ?? 'null') . ' code=' . ($bogusCol['body']['code'] ?? 'null') ); if ($bogusCol['error'] !== null || str_contains(strtolower($bogusCol['raw']), 'unknown column') || str_contains(strtolower($bogusCol['raw']), 'sqlstate') ) { note('BUG: unknown JSON keys are passed into WHERE as tm. — ' . ($bogusCol['error'] ?: 'SQL error in response')); } } else { skip('POST client-scoped filters', 'no sample client/policy'); } // ─── Report ───────────────────────────────────────────────────────────────── ob_end_clean(); foreach ($results as $line) { echo $line . PHP_EOL; } if ($notes !== []) { echo PHP_EOL . '──── Notes / findings ────' . PHP_EOL; foreach ($notes as $n) { echo '- ' . $n . PHP_EOL; } } echo PHP_EOL . "Summary: PASS={$pass} FAIL={$fail} SKIP={$skip}" . PHP_EOL; exit($fail > 0 ? 1 : 0);