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:
322
oauth2/tests/run_tests.php
Normal file
322
oauth2/tests/run_tests.php
Normal 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);
|
||||||
Reference in New Issue
Block a user