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);