-d extension=curl -d extension=openssl test-edu-metrics.php * * The server child is started with the same PHP binary, -n (so the real mysqli * extension is absent and the fake can stand in), openssl for guidv4(), and * output_buffering=4096 as on typical cPanel hosts. * * Exit code 0 when every check passes. */ $endpoint = realpath(__DIR__ . '/../edu_metrics_v002.php'); $fakeDb = __DIR__ . '/fake-db-connect.php'; $tmp = sys_get_temp_dir() . '/edu-metrics-test-' . bin2hex(random_bytes(4)); $util = "{$tmp}/public_html/util"; @mkdir($util, 0777, true); @mkdir("{$tmp}/safePHP", 0777, true); copy($endpoint, "{$util}/edu_metrics_v002.php"); copy($fakeDb, "{$tmp}/safePHP/hc_metrics_db_connect.php"); $phpErrors = "{$tmp}/php_errors.log"; // --- Start the server ------------------------------------------------------ $sock = stream_socket_server('tcp://127.0.0.1:0'); $port = (int)substr(strrchr(stream_socket_get_name($sock, false), ':'), 1); fclose($sock); $extDir = ini_get('extension_dir'); $cmd = [ PHP_BINARY, '-n', '-d', "extension_dir={$extDir}", '-d', 'extension=openssl', '-d', 'error_reporting=' . E_ALL, '-d', 'display_errors=0', '-d', 'log_errors=1', '-d', "error_log={$phpErrors}", // cPanel-like buffering by default; HCM_OUTPUT_BUFFERING=0 proves the // status codes and headers don't depend on it. '-d', 'output_buffering=' . (getenv('HCM_OUTPUT_BUFFERING') !== false ? getenv('HCM_OUTPUT_BUFFERING') : '4096'), '-S', "127.0.0.1:{$port}", '-t', "{$tmp}/public_html", ]; $server = proc_open($cmd, [1 => ['file', "{$tmp}/server.out", 'w'], 2 => ['file', "{$tmp}/server.err", 'w']], $pipes, $util); for ($i = 0; $i < 50; $i++) { $c = @fsockopen('127.0.0.1', $port); if ($c) { fclose($c); break; } usleep(100_000); } // --- Harness --------------------------------------------------------------- $passed = 0; $failures = []; $current = ''; function check(bool $cond, string $what): void { global $passed, $failures, $current; if ($cond) { $passed++; } else { $failures[] = "{$current}: {$what}"; } } /** * POST a raw body to the endpoint. * @return array{code:int,type:string,nosniff:bool,body:string} */ function post(string $body): array { global $port; $headers = []; $ch = curl_init("http://127.0.0.1:{$port}/util/edu_metrics_v002.php"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) { $parts = explode(':', $line, 2); if (count($parts) === 2) { $headers[strtolower(trim($parts[0]))] = trim($parts[1]); } return strlen($line); }, ]); $out = (string)curl_exec($ch); return [ 'code' => (int)curl_getinfo($ch, CURLINFO_HTTP_CODE), 'type' => $headers['content-type'] ?? '', 'nosniff' => strtolower($headers['x-content-type-options'] ?? '') === 'nosniff', 'body' => $out, ]; } /** Reset recorded state and set how many rows the session lookup finds. */ function reset_state(int $selectRows = 0): void { global $tmp, $phpErrors; @unlink("{$tmp}/db.log"); @unlink($phpErrors); file_put_contents("{$tmp}/select_rows.txt", (string)$selectRows); } /** @return array */ function db_log(): array { global $tmp; $f = "{$tmp}/db.log"; if (!is_file($f)) { return []; } return array_map(fn($l) => json_decode($l, true), array_filter(explode("\n", (string)file_get_contents($f)))); } function php_errors(): string { global $phpErrors; clearstatcache(); return is_file($phpErrors) ? trim((string)file_get_contents($phpErrors)) : ''; } /** The DLL's BuildJSON(pGEStart=True / False) output. */ function dll_json(bool $geStart, string $session = 'SESSION-GUID-1'): string { $license = '{"matrix_sn_hash": "abc123", "hclicense": {"dt_expire": 1893456000, "licensee": "Tester"}, "usblicense": {"desc": ""}}'; if ($geStart) { return '{"id_install": "INSTALL-GUID", "harpoon_uuid_session": "' . $session . '", "hc_build": "2026.001", "license": ' . $license . '}'; } return '{"id_install": "INSTALL-GUID", "ge_seconds": 3600, "ge_gametime_seconds": 7200, "harpoon_uuid_session": "' . $session . '", "hc_build": "2026.001", "license": ' . $license . '}'; } function test(string $name, callable $body): void { global $current, $failures; $current = $name; try { $body(); } catch (Throwable $e) { $failures[] = "{$name}: threw " . $e->getMessage(); } } // --- Tests ----------------------------------------------------------------- test('GE start: no PHP warnings (issue 1)', function () { reset_state(); $r = post(dll_json(true)); check($r['code'] === 201, "HTTP 201 (got {$r['code']})"); check(php_errors() === '', 'nothing written to the PHP error log (got: ' . php_errors() . ')'); $log = db_log(); check(count($log) === 2, 'two statements executed (metrics + license), got ' . count($log)); check(str_contains($log[0]['sql'] ?? '', 'INSERT INTO metrics_raw (id, id_install, harpoon_uuid_session, ipv4, hc_build'), 'start row inserted'); check(str_contains($log[1]['sql'] ?? '', 'INSERT INTO license'), 'license recorded'); }); test('GE close with a matching start row: UPDATE', function () { reset_state(1); $r = post(dll_json(false)); check($r['code'] === 201, "HTTP 201 (got {$r['code']})"); check(php_errors() === '', 'no PHP errors (got: ' . php_errors() . ')'); // The session lookup (SELECT) runs first, then the UPDATE. $log = db_log(); $updates = array_values(array_filter($log, fn($e) => str_starts_with($e['sql'], 'UPDATE metrics_raw'))); check(count($updates) === 1, 'one UPDATE executed'); check(($updates[0]['params'] ?? []) == [3600, 7200, 'SESSION-GUID-1'], 'bound seconds, game seconds, session'); check(!array_filter($log, fn($e) => str_starts_with($e['sql'], 'INSERT INTO metrics_raw')), 'no duplicate row inserted'); }); test('GE close with no start row: INSERT succeeds (issue 2)', function () { reset_state(0); $r = post(dll_json(false)); check($r['code'] === 201, "HTTP 201 (got {$r['code']})"); check(!str_contains(php_errors(), 'Column count'), 'no column-count fatal (got: ' . php_errors() . ')'); check(php_errors() === '', 'no PHP errors at all'); $log = db_log(); $insert = array_values(array_filter($log, fn($e) => str_contains($e['sql'], 'INSERT INTO metrics_raw'))); check(count($insert) === 1, 'metrics row inserted'); check(($insert[0]['params'] ?? []) == [$insert[0]['params'][0] ?? null, 'INSTALL-GUID', 'SESSION-GUID-1', 3600, 7200, '127.0.0.1', '2026.001'], 'all seven values bound, including hc_build (got ' . json_encode($insert[0]['params'] ?? null) . ')'); check(count(array_filter($log, fn($e) => str_contains($e['sql'], 'INSERT INTO license'))) === 1, 'license recorded'); }); test('invalid body is not reflected (issue 3)', function () { reset_state(); $r = post(''); check($r['code'] === 500, "HTTP 500 (got {$r['code']})"); check($r['type'] === 'application/json', "Content-Type exactly application/json (got '{$r['type']}')"); check($r['nosniff'], 'X-Content-Type-Options: nosniff'); check(!str_contains($r['body'], ''; $r = post(dll_json(true, $evil)); check($r['code'] === 201, "HTTP 201 (got {$r['code']})"); check($r['type'] === 'application/json', "Content-Type exactly application/json (got '{$r['type']}')"); check($r['nosniff'], 'X-Content-Type-Options: nosniff'); check(!str_contains($r['body'], '<'), 'no raw angle brackets in the body'); $decoded = json_decode($r['body'], true); check(($decoded['status'] ?? null) === 'success', 'status success'); check(($decoded['harpoon_uuid_session'] ?? null) === $evil, 'session id round-trips intact through JSON'); }); test('normal success response keeps its shape for the DLL log', function () { reset_state(); $r = post(dll_json(true, 'ABC-123')); check($r['type'] === 'application/json', "exactly application/json, which IsTextResponse requires (got '{$r['type']}')"); check(json_decode($r['body'], true) === ['status' => 'success', 'harpoon_uuid_session' => 'ABC-123'], 'same fields as before'); }); // --- Teardown -------------------------------------------------------------- proc_terminate($server); proc_close($server); foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $f) { $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); } @rmdir($tmp); $total = $passed + count($failures); echo "{$passed}/{$total} checks passed\n"; foreach ($failures as $f) { echo "FAIL {$f}\n"; } exit($failures ? 1 : 0);