LocalLoginTest.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Auth;
  4. use App\Auth\LoginThrottle;
  5. use App\Http\CsrfMiddleware;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * Drive the local-admin login flow against the real Slim app + a mocked
  9. * api-side `upsertLocal` response. Exercises CSRF, throttle, redirect,
  10. * session-set, and api-down handling.
  11. */
  12. final class LocalLoginTest extends AppTestCase
  13. {
  14. protected function setUp(): void
  15. {
  16. $this->bootApp();
  17. }
  18. public function testGetLoginRendersForm(): void
  19. {
  20. $response = $this->request('GET', '/login');
  21. self::assertSame(200, $response->getStatusCode());
  22. $body = (string) $response->getBody();
  23. self::assertStringContainsString('Sign in', $body);
  24. // Local sign-in toggle present (oidc disabled in this fixture).
  25. self::assertStringContainsString('name="username"', $body);
  26. self::assertStringContainsString('csrf_token', $body);
  27. }
  28. public function testCorrectCredentialsLogInAndRedirectToMe(): void
  29. {
  30. $this->enqueueApiResponse(200, [
  31. 'user_id' => 1,
  32. 'role' => 'admin',
  33. 'email' => null,
  34. 'display_name' => 'Local Admin',
  35. 'is_local' => true,
  36. ]);
  37. // Need a session + csrf token; first GET /login to set one up.
  38. $this->request('GET', '/login');
  39. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  40. self::assertNotEmpty($token);
  41. $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
  42. $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
  43. self::assertSame(303, $response->getStatusCode());
  44. self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
  45. self::assertNotNull($_SESSION['_user'] ?? null);
  46. self::assertSame('admin', $_SESSION['_user']['role']);
  47. }
  48. public function testWrongPasswordRedirectsBackToLoginWithFlash(): void
  49. {
  50. $this->request('GET', '/login');
  51. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  52. $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
  53. $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
  54. self::assertSame(303, $response->getStatusCode());
  55. self::assertSame('/login', $response->getHeaderLine('Location'));
  56. $flash = $_SESSION['_flash'] ?? [];
  57. self::assertNotEmpty($flash);
  58. self::assertSame('error', $flash[0]['type']);
  59. }
  60. public function testWrongUsernameAlsoRecordsFailure(): void
  61. {
  62. $this->request('GET', '/login');
  63. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  64. $body = http_build_query(['csrf_token' => $token, 'username' => 'someone', 'password' => 'test1234']);
  65. $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
  66. // Failure recorded on the LoginThrottle (not in the session).
  67. // Five more attempts from the same IP for the same bogus user
  68. // would lock the bucket; one shot doesn't, so we just verify
  69. // the next attempt isn't locked yet.
  70. /** @var LoginThrottle $throttle */
  71. $throttle = $this->container->get(LoginThrottle::class);
  72. self::assertFalse($throttle->isLocked('someone', ''));
  73. }
  74. public function testCsrfMissingIs403(): void
  75. {
  76. $this->request('GET', '/login');
  77. $body = http_build_query(['username' => 'admin', 'password' => 'test1234']);
  78. $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
  79. self::assertSame(403, $response->getStatusCode());
  80. }
  81. public function testFiveFailuresLockOutNextAttempt(): void
  82. {
  83. $this->request('GET', '/login');
  84. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  85. $bad = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'WRONG']);
  86. for ($i = 0; $i < 5; ++$i) {
  87. $this->request('POST', '/login/local', [], $bad, 'application/x-www-form-urlencoded');
  88. }
  89. // 6th attempt — even with correct credentials — gets the lockout flash.
  90. $this->enqueueApiResponse(200, [
  91. 'user_id' => 1, 'role' => 'admin', 'email' => null, 'display_name' => 'Local Admin', 'is_local' => true,
  92. ]);
  93. $good = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
  94. $response = $this->request('POST', '/login/local', [], $good, 'application/x-www-form-urlencoded');
  95. self::assertSame(303, $response->getStatusCode());
  96. self::assertSame('/login', $response->getHeaderLine('Location'));
  97. $flash = $_SESSION['_flash'] ?? [];
  98. self::assertNotEmpty($flash);
  99. self::assertStringContainsStringIgnoringCase('too many', $flash[0]['message']);
  100. }
  101. public function testApiDownDuringUpsertFlashesError(): void
  102. {
  103. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  104. 'connection refused',
  105. new \GuzzleHttp\Psr7\Request('POST', '/api/v1/auth/users/upsert-local'),
  106. ));
  107. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  108. 'connection refused',
  109. new \GuzzleHttp\Psr7\Request('POST', '/api/v1/auth/users/upsert-local'),
  110. ));
  111. $this->request('GET', '/login');
  112. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  113. $body = http_build_query(['csrf_token' => $token, 'username' => 'admin', 'password' => 'test1234']);
  114. $response = $this->request('POST', '/login/local', [], $body, 'application/x-www-form-urlencoded');
  115. self::assertSame(303, $response->getStatusCode());
  116. self::assertSame('/login', $response->getHeaderLine('Location'));
  117. $flash = $_SESSION['_flash'] ?? [];
  118. self::assertNotEmpty($flash);
  119. self::assertStringContainsStringIgnoringCase('api', $flash[0]['message']);
  120. }
  121. }