From b416bf50610f34cd6560d69a91ad02d74b11f493 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 7 Jul 2026 22:53:31 +0300 Subject: [PATCH] oauth2: teste automate pentru index.php + pick.php (php -S + HTTP) Matricea ceruta: pick.php (state invalid/lipsa -> 400, pending, livrare o singura data, TTL 600s, claim concurent) + index.php (refresh POST/GET passthrough - regresia becbbe2, 302 authorize fara client_secret, state invalid -> pagina eroare, callback ?error= cu/fara sesiune, flux complet eroare pana la pick.php). Rulare: php oauth2/tests/run_tests.php (34 asertiuni, exit 0/1). Ruleaza pe copie in director temporar - nu atinge oauth2/tokens/ din repo. Co-Authored-By: Claude Fable 5 --- oauth2/tests/run_tests.php | 322 +++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 oauth2/tests/run_tests.php diff --git a/oauth2/tests/run_tests.php b/oauth2/tests/run_tests.php new file mode 100644 index 0000000..dd734fb --- /dev/null +++ b/oauth2/tests/run_tests.php @@ -0,0 +1,322 @@ + int, 'headers' => array, 'body' => string] +// =================================================================== +function http($method, $url, $post = null, $cookie = null, $timeout = 45) { + $header = "Connection: close\r\n"; + if ($cookie !== null) { + $header .= 'Cookie: ' . $cookie . "\r\n"; + } + $opts = ['http' => [ + 'method' => $method, + 'ignore_errors' => true, // returneaza corpul si la 4xx/5xx + 'follow_location' => 0, // vrem sa vedem 302-ul, nu sa-l urmam + 'timeout' => $timeout, + ]]; + if ($post !== null) { + $header .= "Content-Type: application/x-www-form-urlencoded\r\n"; + $opts['http']['content'] = http_build_query($post); + } + $opts['http']['header'] = $header; + $body = @file_get_contents($url, false, stream_context_create($opts)); + $headers = isset($http_response_header) ? $http_response_header : []; + $status = 0; + if (!empty($headers) && preg_match('#^HTTP/\S+\s+(\d{3})#', $headers[0], $m)) { + $status = (int)$m[1]; + } + return ['status' => $status, 'headers' => $headers, 'body' => (string)$body]; +} + +// Cauta un header dupa nume (case-insensitive); null daca lipseste +function hdr($resp, $name) { + foreach ($resp['headers'] as $h) { + if (stripos($h, $name . ':') === 0) { + return trim(substr($h, strlen($name) + 1)); + } + } + return null; +} + +// =================================================================== +// Mod copil pentru testul de claim concurent: +// php run_tests.php --pick-once +// Face un singur POST pe pick.php si scrie corpul raspunsului pe stdout. +// =================================================================== +if (isset($argv[1]) && $argv[1] === '--pick-once') { + $r = http('POST', $argv[2], ['state' => $argv[3]]); + echo $r['body']; + exit(0); +} + +// =================================================================== +// Setup: director temporar + server php -S +// =================================================================== +$tmp = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'roa_oauth2_tests_' . getmypid(); +$sessions = $tmp . DIRECTORY_SEPARATOR . 'sessions'; +$tokens = $tmp . DIRECTORY_SEPARATOR . 'tokens'; +if (!mkdir($tmp, 0700, true) || !mkdir($sessions, 0700, true)) { + fwrite(STDERR, "Nu pot crea directorul temporar: $tmp\n"); + exit(2); +} +copy(__DIR__ . '/../index.php', $tmp . DIRECTORY_SEPARATOR . 'index.php'); +copy(__DIR__ . '/../pick.php', $tmp . DIRECTORY_SEPARATOR . 'pick.php'); + +$server = proc_open( + [PHP_BINARY, '-S', '127.0.0.1:8317', '-t', $tmp, + '-d', 'session.save_path=' . $sessions, + '-d', 'xdebug.mode=off'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes +); +if (!is_resource($server)) { + fwrite(STDERR, "Nu pot porni php -S\n"); + exit(2); +} +register_shutdown_function(function () use ($server, $tmp) { + @proc_terminate($server); + @proc_close($server); + // curatare best-effort a directorului temporar + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($tmp, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $f) { + $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); + } + @rmdir($tmp); +}); + +// Asteapta serverul (max 5 secunde) +$ready = false; +for ($i = 0; $i < 50; $i++) { + $s = @fsockopen('127.0.0.1', 8317, $en, $es, 0.1); + if ($s) { fclose($s); $ready = true; break; } + usleep(100000); +} +if (!$ready) { + fwrite(STDERR, "Serverul php -S nu a pornit pe 127.0.0.1:8317\n"); + exit(2); +} + +// =================================================================== +// Asertiuni +// =================================================================== +$pass = 0; +$fail = 0; +function check($name, $cond, $detail = '') { + global $pass, $fail; + if ($cond) { + $pass++; + echo " OK $name\n"; + } else { + $fail++; + echo " FAIL $name" . ($detail !== '' ? " [$detail]" : '') . "\n"; + } +} +function state_nou() { + return bin2hex(random_bytes(32)); // 64 caractere hex = format valid +} +function fisier_token($tokens, $state) { + return $tokens . DIRECTORY_SEPARATOR . hash('sha256', $state) . '.json'; +} + +// =================================================================== +// pick.php +// =================================================================== +echo "pick.php\n"; + +// P1: fara state -> 400 invalid_state +$r = http('POST', BASE . '/pick.php', []); +check('P1 fara state -> 400 invalid_state', + $r['status'] === 400 && strpos($r['body'], 'invalid_state') !== false, + "status={$r['status']} body={$r['body']}"); + +// P2: state prea scurt -> 400 +$r = http('POST', BASE . '/pick.php', ['state' => 'abc123']); +check('P2 state prea scurt -> 400', $r['status'] === 400, "status={$r['status']}"); + +// P3: lungime 64 dar caractere invalide (path traversal) -> 400 +$r = http('POST', BASE . '/pick.php', ['state' => str_repeat('a', 60) . '/../']); +check('P3 caractere invalide -> 400', $r['status'] === 400, "status={$r['status']}"); + +// P4: state valid, fisier inexistent -> 200 pending +$s = state_nou(); +$r = http('POST', BASE . '/pick.php', ['state' => $s]); +check('P4 fara fisier -> 200 {"status":"pending"}', + $r['status'] === 200 && $r['body'] === '{"status":"pending"}', + "status={$r['status']} body={$r['body']}"); +check('P4 Content-Type JSON', + stripos((string)hdr($r, 'Content-Type'), 'application/json') !== false); + +// FR-11: prima cerere a creat tokens/ + .htaccess "Require all denied" +check('FR-11 tokens/.htaccess creat automat', + is_file($tokens . '/.htaccess') + && strpos((string)file_get_contents($tokens . '/.htaccess'), 'Require all denied') !== false); + +// P5: fisier prezent -> continutul exact, O SINGURA data, apoi pending +$s = state_nou(); +$continut = '{"access_token":"AT_test","refresh_token":"RT_test","expires_in":3600}'; +file_put_contents(fisier_token($tokens, $s), $continut); +$r = http('POST', BASE . '/pick.php', ['state' => $s]); +check('P5 fisier prezent -> continutul exact', $r['body'] === $continut, + "body={$r['body']}"); +check('P5 fisierul e sters dupa livrare', !file_exists(fisier_token($tokens, $s))); +$r = http('POST', BASE . '/pick.php', ['state' => $s]); +check('P5 a doua cerere -> pending', $r['body'] === '{"status":"pending"}', + "body={$r['body']}"); + +// P6: TTL — fisier mai vechi de 10 minute -> sters, raspuns pending +$s = state_nou(); +file_put_contents(fisier_token($tokens, $s), $continut); +touch(fisier_token($tokens, $s), time() - 700); +$r = http('POST', BASE . '/pick.php', ['state' => $s]); +check('P6 fisier expirat (TTL 600s) -> pending', $r['body'] === '{"status":"pending"}', + "body={$r['body']}"); +check('P6 fisierul expirat e sters', !file_exists(fisier_token($tokens, $s))); + +// P7: TTL curata si .tmp orfane vechi +$tmp_orfan = $tokens . DIRECTORY_SEPARATOR . 'orfan.tmp'; +file_put_contents($tmp_orfan, 'x'); +touch($tmp_orfan, time() - 700); +http('POST', BASE . '/pick.php', ['state' => state_nou()]); +check('P7 .tmp orfan vechi e sters de TTL', !file_exists($tmp_orfan)); + +// P8: claim concurent — doua cereri simultane, exact una primeste tokenul. +// Nota: php -S pe Windows serveste secvential; concurenta reala e pe partea de +// client, dar mecanismul testat (rename atomic in pick.php) acopera ambele cazuri. +$s = state_nou(); +file_put_contents(fisier_token($tokens, $s), $continut); +$copii = []; +$pipe = []; +for ($i = 0; $i < 2; $i++) { + $copii[$i] = proc_open( + [PHP_BINARY, '-d', 'xdebug.mode=off', __FILE__, '--pick-once', BASE . '/pick.php', $s], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipe[$i] + ); +} +$rasp = []; +for ($i = 0; $i < 2; $i++) { + $rasp[$i] = stream_get_contents($pipe[$i][1]); + proc_close($copii[$i]); +} +$cu_token = 0; +$cu_pending = 0; +foreach ($rasp as $b) { + if ($b === $continut) $cu_token++; + if ($b === '{"status":"pending"}') $cu_pending++; +} +check('P8 claim concurent: exact una primeste tokenul, cealalta pending', + $cu_token === 1 && $cu_pending === 1, + 'raspunsuri: [' . implode('] [', $rasp) . ']'); + +// =================================================================== +// index.php +// =================================================================== +echo "index.php\n"; + +// I1: fara parametri -> 302 catre authorize, FARA client_secret, fara state +$r = http('GET', BASE . '/index.php'); +$loc = (string)hdr($r, 'Location'); +check('I1 fara parametri -> 302', $r['status'] === 302, "status={$r['status']}"); +check('I1 Location = ANAF authorize', + strpos($loc, 'https://logincert.anaf.ro/anaf-oauth2/v1/authorize?') === 0, "loc=$loc"); +check('I1 authorize contine response_type=code + jwt', + strpos($loc, 'response_type=code') !== false + && strpos($loc, 'token_content_type=jwt') !== false); +check('I1 client_secret NU apare in URL-ul authorize', + strpos($loc, 'client_secret') === false, "loc=$loc"); +check('I1 fara state in URL cand nu s-a trimis state', + strpos($loc, 'state=') === false, "loc=$loc"); + +// I2: ?state=valid -> 302 cu state propagat + cookie de sesiune +$s = state_nou(); +$r = http('GET', BASE . '/index.php?state=' . $s); +$loc = (string)hdr($r, 'Location'); +check('I2 ?state=valid -> 302 spre authorize', $r['status'] === 302 + && strpos($loc, 'https://logincert.anaf.ro/anaf-oauth2/v1/authorize?') === 0, + "status={$r['status']}"); +check('I2 state propagat in authorize', strpos($loc, 'state=' . $s) !== false, "loc=$loc"); +check('I2 client_secret NU apare', strpos($loc, 'client_secret') === false); +$setc = (string)hdr($r, 'Set-Cookie'); +check('I2 Set-Cookie sesiune PHP', strpos($setc, 'PHPSESSID=') !== false, "set-cookie=$setc"); + +// I3: ?state cu format invalid -> pagina de eroare, fara redirect +$r = http('GET', BASE . '/index.php?state=' . str_repeat('a', 60) . '..%2F.'); +check('I3 state invalid -> 200 pagina eroare (nu redirect)', + $r['status'] === 200 && hdr($r, 'Location') === null, "status={$r['status']}"); +check('I3 pagina contine "Autorizarea nu a reușit"', + strpos($r['body'], 'Autorizarea nu a reușit') !== false); + +// I4: refresh prin POST body -> passthrough JSON (regresia fixata in becbbe2: +// inainte, POST-ul era ignorat si cererea cadea pe 302 authorize) +$r = http('POST', BASE . '/index.php', ['refresh_token' => 'dummy_refresh_token_test']); +check('I4 refresh POST -> nu e 302 (regresie buton Actualizare)', + $r['status'] !== 302, "status={$r['status']}"); +check('I4 refresh POST -> Content-Type application/json', + stripos((string)hdr($r, 'Content-Type'), 'application/json') !== false, + 'content-type=' . hdr($r, 'Content-Type')); + +// I5: refresh prin GET (compatibilitate retro) -> acelasi passthrough +$r = http('GET', BASE . '/index.php?refresh_token=dummy_refresh_token_test'); +check('I5 refresh GET -> nu e 302', $r['status'] !== 302, "status={$r['status']}"); +check('I5 refresh GET -> Content-Type application/json', + stripos((string)hdr($r, 'Content-Type'), 'application/json') !== false, + 'content-type=' . hdr($r, 'Content-Type')); + +// I6: callback ?error= fara sesiune -> pagina de eroare, fara fisier scris +$inainte = count((array)glob($tokens . '/*.json')); +$r = http('GET', BASE . '/index.php?error=access_denied&error_description=Test'); +check('I6 ?error= fara sesiune -> pagina eroare', + $r['status'] === 200 && strpos($r['body'], 'Autorizarea nu a reușit') !== false, + "status={$r['status']}"); +check('I6 nu se scrie fisier de token fara sesiune', + count((array)glob($tokens . '/*.json')) === $inainte); + +// I7: flux complet de eroare: intrare cu state (sesiune) -> callback ?error= +// -> fisier de eroare scris -> pick.php il livreaza o singura data +$s = state_nou(); +$r = http('GET', BASE . '/index.php?state=' . $s); +$cookie = null; +if (preg_match('/PHPSESSID=([^;]+)/', (string)hdr($r, 'Set-Cookie'), $m)) { + $cookie = 'PHPSESSID=' . $m[1]; +} +check('I7 intrare cu state -> cookie sesiune obtinut', $cookie !== null); +$r = http('GET', BASE . '/index.php?error=access_denied&error_description=Utilizatorul+a+refuzat', + null, $cookie); +check('I7 callback ?error= cu sesiune -> pagina eroare', + $r['status'] === 200 && strpos($r['body'], 'Autorizarea nu a reușit') !== false, + "status={$r['status']}"); +$r = http('POST', BASE . '/pick.php', ['state' => $s]); +$j = json_decode($r['body'], true); +check('I7 pick.php livreaza eroarea ANAF', + is_array($j) && isset($j['error']) && $j['error'] === 'access_denied', + "body={$r['body']}"); +$r = http('POST', BASE . '/pick.php', ['state' => $s]); +check('I7 a doua cerere pick -> pending', $r['body'] === '{"status":"pending"}', + "body={$r['body']}"); + +// =================================================================== +// Rezumat +// =================================================================== +echo "\n$pass trecute, $fail esuate\n"; +exit($fail === 0 ? 0 : 1);