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 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:53:31 +03:00
parent becbbe2be4
commit b416bf5061

322
oauth2/tests/run_tests.php Normal file
View File

@@ -0,0 +1,322 @@
<?php
// run_tests.php — teste pentru oauth2/index.php + oauth2/pick.php (flux automat token ANAF)
//
// Rulare: php oauth2/tests/run_tests.php
//
// Nu exista framework de teste in proiect; scriptul este self-contained:
// - copiaza index.php si pick.php intr-un director temporar (nu atinge tokens/ din repo),
// - porneste "php -S 127.0.0.1:8317" pe copie, cu sesiuni intr-un save_path propriu,
// - ruleaza matricea de teste cu cereri HTTP (streams, fara dependinte externe),
// - opreste serverul si curata directorul temporar; exit code 0 = toate au trecut.
//
// NOTA retea: testele de refresh (passthrough) declanseaza in index.php un apel curl
// real catre logincert.anaf.ro cu un refresh_token fictiv (ANAF raspunde invalid_grant).
// Asertiunile (nu e 302, Content-Type JSON) raman valabile si offline (corp gol).
error_reporting(E_ALL);
const BASE = 'http://127.0.0.1:8317';
// ===================================================================
// Helper HTTP: cerere fara follow-redirect, cu cookie optional
// Returneaza ['status' => 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 <url> <state>
// 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);