Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 309 additions & 0 deletions tools/login_flow_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);

/**
* RiskOps - Giris akisi (login flow) sozlesme regresyon testi
* Calistirma: php tools/login_flow_test.php
*
* BAGIMLILIK: yerel MariaDB (Docker konteyneri "riskops-mariadb",
* 127.0.0.1:3306, config/database.php). Sunucu yoksa test ACIKCA
* basarisiz olur (tools/smoke_test.php ile ayni davranis).
*
* KAPSAM: GERCEK auth/authenticate.php isleyicisi cocuk sureclerde
* calistirilir (gercek oturum + CSRF + veritabani; yalnizca web ortami
* $_SERVER/$_POST ile simule edilir) ve belgelenmis sozlesme
* dogrulanir. Bu test bilinen bir hatayi degil, giris akisinin
* SOZLESMESINI regresyona karsi korur (round-7 E2E avindan
* kalicilastirilmistir):
*
* S0 gecersiz CSRF -> 403 ABORT, hicbir DB yan etkisi yok.
* S1 gecerli giris -> users.last_login_at guncellenir, basarili
* deneme satiri yazilir, 'login' audit kaydi olur.
* S2 yanlis parola -> basarisiz deneme satiri + 'login_failed'.
* S3 ardan basarili giris -> bu e-postanin basarisiz satirlari
* silinir (kilit kalkar), last_login_at yine guncellenir.
* S4 max_login_attempts yanlis denemeden sonra DOGRU parola bile
* reddedilir: 'login_rate_limited', last_login_at DEGISMEZ ve
* rate-limit yolu YENI deneme satiri YAZMAZ.
* S5 status=0 hesap -> 'login_disabled_account', basarisiz satiri.
*
* users / login_attempts / audit_logs anlik goruntusu alinip sonda
* geri yuklenir; test IP'si 127.0.0.7 ile izole edilir. NOT:
* authenticate.php gecerli ilk giriste parola hash'ini
* PASSWORD_DEFAULT'a yeniden hesaplayabilir - bu yuzden users.password
* da yedeklenip geri yuklenir.
*/

if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('CLI only.');
}

$repoRoot = dirname(__DIR__);

$PASS = 0;
$FAIL = 0;

function check(string $label, bool $ok, string $detail = ''): void
{
global $PASS, $FAIL;
if ($ok) {
$PASS++;
printf(" [ OK ] %-56s %s\n", $label, $detail);
} else {
$FAIL++;
printf(" [FAIL] %-56s %s\n", $label, $detail);
}
}

function section(string $title): void
{
echo "\n" . str_repeat('-', 72) . "\n " . $title . "\n" . str_repeat('-', 72) . "\n";
}

/* ---- Calisma aninda uretilen istek cocugu (repo DISI gecici dizin) -- */

$workerSrc = <<<'PHP'
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli' || $argc < 3) { fwrite(STDERR, "bad invocation\n"); exit(2); }
[, $cfgFile, $repoRoot] = $argv;
$cfg = json_decode((string)file_get_contents($cfgFile), true) ?: [];

$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REMOTE_ADDR'] = '127.0.0.7';
$_SERVER['HTTP_USER_AGENT'] = 'login-flow-test';
$_SERVER['REQUEST_URI'] = '/auth/authenticate.php';

$_POST = [
'email' => (string)($cfg['email'] ?? ''),
'password' => (string)($cfg['password'] ?? ''),
];

// CLI'da bootstrap oturum blogunu atlar; oturumu test acar (dosya
// handler'i). Boylece csrf_verify / auth_start / flash uretimdeki
// gibi calisir; redirect() exit(0), app_abort STDERR'da ABORT + exit(1).
session_name('RISKOPS_SESSION');
session_start();
$_SESSION['_csrf'] = 'login-flow-test-token';
$_POST['_token'] = ($cfg['token_valid'] ?? true) ? 'login-flow-test-token' : 'WRONG-TOKEN';

require $repoRoot . '/auth/authenticate.php';
PHP;

$tmp = sys_get_temp_dir() . '/riskops-login-test-' . getmypid();
if (!is_dir($tmp)) {
mkdir($tmp, 0777, true);
}
$workerFile = $tmp . '/request.php';
file_put_contents($workerFile, $workerSrc);

/* ---- Ortam ----------------------------------------------------------- */

try {
$pdo = new PDO(
'mysql:host=127.0.0.1;port=3306;dbname=riskops;charset=utf8mb4',
'riskops_user',
'riskops_local_dev',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The test ignores config/database.php and always connects with the hard-coded riskops_local_dev password. It therefore fails on the documented installation unless the local database happens to use these exact credentials; load the application database configuration instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/login_flow_test.php, line 107:

<comment>The test ignores `config/database.php` and always connects with the hard-coded `riskops_local_dev` password. It therefore fails on the documented installation unless the local database happens to use these exact credentials; load the application database configuration instead.</comment>

<file context>
@@ -0,0 +1,309 @@
+    $pdo = new PDO(
+        'mysql:host=127.0.0.1;port=3306;dbname=riskops;charset=utf8mb4',
+        'riskops_user',
+        'riskops_local_dev',
+        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+         PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
</file context>

[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 5]
);
} catch (Throwable $e) {
echo "\nYerel MariaDB'ye baglanilamadi (konteyner: docker start riskops-mariadb).\n";
echo 'Hata: ' . $e->getMessage() . "\n";
echo "LOGIN FLOW TEST: FAIL\n";
exit(1);
}

/* --- Snapshot ---------------------------------------------------------- */

$usersSnap = $pdo->query(
'SELECT id, password, status, must_change_password, last_login_at FROM users ORDER BY id'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If the admin hash needs rehashing, running this test permanently changes password_changed_at despite the advertised full restoration. Snapshot and restore password_changed_at together with the password.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/login_flow_test.php, line 122:

<comment>If the admin hash needs rehashing, running this test permanently changes `password_changed_at` despite the advertised full restoration. Snapshot and restore `password_changed_at` together with the password.</comment>

<file context>
@@ -0,0 +1,309 @@
+/* --- Snapshot ---------------------------------------------------------- */
+
+$usersSnap = $pdo->query(
+    'SELECT id, password, status, must_change_password, last_login_at FROM users ORDER BY id'
+)->fetchAll();
+$laMax = (int)$pdo->query('SELECT COALESCE(MAX(id),0) FROM login_attempts')->fetchColumn();
</file context>

)->fetchAll();
$laMax = (int)$pdo->query('SELECT COALESCE(MAX(id),0) FROM login_attempts')->fetchColumn();
$auMax = (int)$pdo->query('SELECT COALESCE(MAX(id),0) FROM audit_logs')->fetchColumn();

$restore = static function () use ($pdo, $usersSnap, $laMax, $auMax): void {
$pdo->exec("DELETE FROM users WHERE email LIKE 'login-flow-test.%@riskops.local'");
$pdo->prepare('DELETE FROM login_attempts WHERE id > :m OR ip_address = :ip')
->execute([':m' => $laMax, ':ip' => '127.0.0.7']);
$pdo->prepare('DELETE FROM audit_logs WHERE id > :m')->execute([':m' => $auMax]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the application is used concurrently, this cleanup deletes unrelated audit and login-attempt records and overwrites concurrent user changes. Run against an isolated database or tag and restore only records created by this test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/login_flow_test.php, line 131:

<comment>When the application is used concurrently, this cleanup deletes unrelated audit and login-attempt records and overwrites concurrent user changes. Run against an isolated database or tag and restore only records created by this test.</comment>

<file context>
@@ -0,0 +1,309 @@
+    $pdo->exec("DELETE FROM users WHERE email LIKE 'login-flow-test.%@riskops.local'");
+    $pdo->prepare('DELETE FROM login_attempts WHERE id > :m OR ip_address = :ip')
+        ->execute([':m' => $laMax, ':ip' => '127.0.0.7']);
+    $pdo->prepare('DELETE FROM audit_logs WHERE id > :m')->execute([':m' => $auMax]);
+    $upd = $pdo->prepare(
+        'UPDATE users SET password = :p, status = :s, must_change_password = :mcp,
</file context>

$upd = $pdo->prepare(
'UPDATE users SET password = :p, status = :s, must_change_password = :mcp,
last_login_at = :ll WHERE id = :id'
);
foreach ($usersSnap as $u) {
$upd->execute([':p' => $u['password'], ':s' => $u['status'],
':mcp' => $u['must_change_password'], ':ll' => $u['last_login_at'],
':id' => $u['id']]);
}
};

/** Bir "giris istegi" calistirir: [exitCode, stderr]. */
function run_login_request(array $cfg, string $tmp, string $workerFile, string $repoRoot): array
{
file_put_contents($tmp . '/cfg.json', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$cmd = 'php ' . escapeshellarg($workerFile) . ' ' . escapeshellarg($tmp . '/cfg.json')
. ' ' . escapeshellarg($repoRoot) . ' 2>' . escapeshellarg($tmp . '/stderr.txt');
exec($cmd, $out, $code);
return [$code, (string)@file_get_contents($tmp . '/stderr.txt')];
}

/** Snapshot'tan bu yana yazilan audit action'lari. */
function audits_since(PDO $pdo, int $auMax): array
{
$st = $pdo->prepare('SELECT action FROM audit_logs WHERE id > :m ORDER BY id');
$st->execute([':m' => $auMax]);
return array_column($st->fetchAll(), 'action');
}

/** Test IP'si icin e-posta bazli basarisiz deneme sayisi. */
function fail_count(PDO $pdo, string $email): int
{
$st = $pdo->prepare('SELECT COUNT(*) FROM login_attempts WHERE success = 0 AND email = :e');
$st->execute([':e' => $email]);
return (int)$st->fetchColumn();
}

function last_login_of(PDO $pdo, string $email): string
{
$st = $pdo->prepare('SELECT last_login_at FROM users WHERE email = :e');
$st->execute([':e' => $email]);
return (string)$st->fetchColumn();
}

echo "\n================= Login Flow Contract Test =================";

try {
$adminEmail = 'admin@riskops.local';
$adminPass = 'Admin123456';
$maxAttempts = max(1, (int)$pdo->query(
"SELECT setting_value FROM settings WHERE setting_key = 'max_login_attempts'"
)->fetchColumn() ?: 5);

/* ------------------------------------------------------------------ */
section('1) CSRF ve gecerli giris');
/* ------------------------------------------------------------------ */

$t0 = date('Y-m-d H:i:s', time() - 1);
[$code, $err] = run_login_request(
['email' => $adminEmail, 'password' => 'x', 'token_valid' => false],
$tmp, $workerFile, $repoRoot
);
$sideEffects = (int)$pdo->query(
"SELECT (SELECT COUNT(*) FROM login_attempts WHERE ip_address = '127.0.0.7')"
. " + (SELECT COUNT(*) FROM users WHERE last_login_at >= '{$t0}')"
)->fetchColumn();
check('gecersiz CSRF: 403 ABORT ve DB yan etkisi yok',
$code !== 0 && str_contains($err, 'ABORT 403') && $sideEffects === 0,
"exit={$code} effects={$sideEffects}");

[$code] = run_login_request(['email' => $adminEmail, 'password' => $adminPass], $tmp, $workerFile, $repoRoot);
$successRows = (int)$pdo->query(
"SELECT COUNT(*) FROM login_attempts WHERE success = 1 AND email = '{$adminEmail}'"
)->fetchColumn();
check('gecerli giris: last_login_at + basarili satir + login audit',
$code === 0 && last_login_of($pdo, $adminEmail) >= $t0 && $successRows === 1
&& in_array('login', audits_since($pdo, $auMax), true),
Comment on lines +207 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The section-1 valid-login check asserts $successRows === 1, but $successRows counts every successful login_attempts row ever written for admin@riskops.local (no id or ip_address filter). Any dev DB where the admin has logged in before — via the web UI, another test IP, or a prior run — already has successful rows, so this check always fails and the whole test reports false FAIL on a non-fresh database. Restrict the count to rows created by this run, e.g. AND id > {$laMax} (or AND ip_address = '127.0.0.7'), consistent with the snapshot/restore boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tools/login_flow_test.php, line 207:

<comment>The section-1 valid-login check asserts `$successRows === 1`, but `$successRows` counts every successful `login_attempts` row ever written for `admin@riskops.local` (no `id` or `ip_address` filter). Any dev DB where the admin has logged in before — via the web UI, another test IP, or a prior run — already has successful rows, so this check always fails and the whole test reports false FAIL on a non-fresh database. Restrict the count to rows created by this run, e.g. `AND id > {$laMax}` (or `AND ip_address = '127.0.0.7'`), consistent with the snapshot/restore boundary.</comment>

<file context>
@@ -0,0 +1,309 @@
+        "SELECT COUNT(*) FROM login_attempts WHERE success = 1 AND email = '{$adminEmail}'"
+    )->fetchColumn();
+    check('gecerli giris: last_login_at + basarili satir + login audit',
+        $code === 0 && last_login_of($pdo, $adminEmail) >= $t0 && $successRows === 1
+        && in_array('login', audits_since($pdo, $auMax), true),
+        'll=' . last_login_of($pdo, $adminEmail) . ' ok_rows=' . $successRows);
</file context>
Suggested change
$code === 0 && last_login_of($pdo, $adminEmail) >= $t0 && $successRows === 1
&& in_array('login', audits_since($pdo, $auMax), true),
$successRows = (int)$pdo->query(
"SELECT COUNT(*) FROM login_attempts WHERE success = 1 AND email = '{$adminEmail}' AND id > {$laMax}"
)->fetchColumn();

'll=' . last_login_of($pdo, $adminEmail) . ' ok_rows=' . $successRows);

/* ------------------------------------------------------------------ */
section('2) Basarisiz denemeler ve kilit acma');
/* ------------------------------------------------------------------ */

run_login_request(['email' => $adminEmail, 'password' => 'WRONG-1'], $tmp, $workerFile, $repoRoot);
run_login_request(['email' => $adminEmail, 'password' => 'WRONG-2'], $tmp, $workerFile, $repoRoot);
check('yanlis parola: basarisiz satir + login_failed audit',
fail_count($pdo, $adminEmail) === 2
&& count(array_keys(audits_since($pdo, $auMax), 'login_failed', true)) >= 2,
'fails=' . fail_count($pdo, $adminEmail));

$beforeS3 = last_login_of($pdo, $adminEmail);
sleep(1);
[$code] = run_login_request(['email' => $adminEmail, 'password' => $adminPass], $tmp, $workerFile, $repoRoot);
check('basari, basarisiz satirlari temizler (kilit kalkar)',
$code === 0 && fail_count($pdo, $adminEmail) === 0 && last_login_of($pdo, $adminEmail) > $beforeS3,
'fails=' . fail_count($pdo, $adminEmail));

/* ------------------------------------------------------------------ */
section("3) Brute-force kilidi (max_login_attempts={$maxAttempts})");
/* ------------------------------------------------------------------ */

for ($i = 1; $i <= $maxAttempts; $i++) {
run_login_request(['email' => $adminEmail, 'password' => "WRONG-{$i}"], $tmp, $workerFile, $repoRoot);
}
$lockedAt = last_login_of($pdo, $adminEmail);
$failsBefore = fail_count($pdo, $adminEmail);

run_login_request(['email' => $adminEmail, 'password' => $adminPass], $tmp, $workerFile, $repoRoot);
$rateLimited = in_array('login_rate_limited', audits_since($pdo, $auMax), true);
check('kilit: DOGRU parola bile reddedilir, last_login degismez',
last_login_of($pdo, $adminEmail) === $lockedAt && $rateLimited,
'll_ayni=' . var_export(last_login_of($pdo, $adminEmail) === $lockedAt, true)
. ' rate_limited=' . var_export($rateLimited, true));
check('rate-limit yolu YENI deneme satiri yazmaz',
fail_count($pdo, $adminEmail) === $failsBefore && $failsBefore === $maxAttempts,
'fails=' . fail_count($pdo, $adminEmail));

/* ------------------------------------------------------------------ */
section('4) Pasif hesap');
/* ------------------------------------------------------------------ */

$ins = $pdo->prepare(
'INSERT INTO users (name, email, password, role, status) VALUES (:n, :e, :p, \'viewer\', 0)'
);
$ins->execute([':n' => 'Login Flow Disabled', ':e' => 'login-flow-test.disabled@riskops.local',
':p' => password_hash('Disabled123', PASSWORD_DEFAULT)]);
run_login_request(
['email' => 'login-flow-test.disabled@riskops.local', 'password' => 'Disabled123'],
$tmp, $workerFile, $repoRoot
);
check('pasif hesap: login_disabled_account + basarisiz satir',
in_array('login_disabled_account', audits_since($pdo, $auMax), true)
&& fail_count($pdo, 'login-flow-test.disabled@riskops.local') === 1);
} finally {
$restore();
}

/* ------------------------------------------------------------------ */
section('5) Geri yukleme');
/* ------------------------------------------------------------------ */

$leftUsers = (int)$pdo->query(
"SELECT COUNT(*) FROM users WHERE email LIKE 'login-flow-test.%@riskops.local'"
)->fetchColumn();
$leftAttempts = (int)$pdo->query(
"SELECT COUNT(*) FROM login_attempts WHERE ip_address = '127.0.0.7' OR id > {$laMax}"
)->fetchColumn();
$leftAudit = (int)$pdo->query("SELECT COUNT(*) FROM audit_logs WHERE id > {$auMax}")->fetchColumn();
$hashRestored = true;
$recheck = $pdo->query('SELECT id, password FROM users ORDER BY id')->fetchAll();
foreach ($recheck as $i => $row) {
if (($usersSnap[$i]['id'] ?? null) !== $row['id']
|| ($usersSnap[$i]['password'] ?? null) !== $row['password']) {
$hashRestored = false;
}
}
check('scratch kayitlar ve test satirlari temizlendi',
$leftUsers === 0 && $leftAttempts === 0 && $leftAudit === 0,
"users={$leftUsers} attempts={$leftAttempts} audit={$leftAudit}");
check('users.password dahil anlik durum geri yuklendi', $hashRestored);

/* --- Gecici cocuk/ayar dosyalarini sil --------------------------------- */

@unlink($workerFile);
@unlink($tmp . '/cfg.json');
@unlink($tmp . '/stderr.txt');
@rmdir($tmp);

/* ------------------------------------------------------------------ */

echo "\n" . str_repeat('-', 72) . "\n";
printf("Sonuc: %d OK, %d FAIL\n", $PASS, $FAIL);
if ($FAIL > 0) {
echo "LOGIN FLOW TEST: FAIL\n";
exit(1);
}
echo "LOGIN FLOW TEST: PASS\n";
exit(0);