primeGet('/');
$bodyA = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$tokenA = $this->extractMetaToken($bodyA);
// 2. Now simulate a server restart with SECRET_B. The browser
// still has the old cookies (minted under SECRET_A). We capture
// them from the first request's response by re-running a request
// with the same env: but since we cannot read Set-Cookie headers
// from a captured PHP test, we re-derive them using the same
// secret + the raw token the controller issued.
$cookieA = hash_hmac('sha256', $tokenA, self::SECRET_A);
// 3. Send a second request with the stale cookies but SECRET_B
// active. The front controller should detect the mismatch and
// re-mint a fresh pair.
putenv('BATTLEFORGE_SECRET=' . self::SECRET_B);
$_COOKIE['__csrf'] = $cookieA;
$_COOKIE['__csrf_token'] = $tokenA;
$this->primeGet('/');
$bodyB = $this->runFrontController();
// 4. The response should now carry a meta tag with a different
// raw token, and that token should HMAC under SECRET_B.
$tokenB = $this->extractMetaToken($bodyB);
self::assertNotSame($tokenA, $tokenB, 'raw token should be re-minted under SECRET_B');
$hmacUnderB = hash_hmac('sha256', $tokenB, self::SECRET_B);
self::assertSame(64, strlen($hmacUnderB), 'HMAC under SECRET_B should be 64 hex chars');
}
public function testValidCookiesAreNotReIssuedOnNextRequest(): void
{
// Mint a fresh pair under SECRET_A and capture both the raw token
// and the corresponding HMAC. The test sends these as the browser's
// cookie jar on the second request; the controller should recognise
// them as fresh and re-use the existing pair (no re-mint).
putenv('BATTLEFORGE_SECRET=' . self::SECRET_A);
[$tokenA, $cookieA] = CsrfToken::issue(self::SECRET_A);
// 1. First request: no cookies, controller mints a fresh pair.
$this->primeGet('/');
$bodyA = $this->runFrontController();
self::assertSame(200, $this->lastStatus);
$tokenAController = $this->extractMetaToken($bodyA);
$cookieAController = hash_hmac('sha256', $tokenAController, self::SECRET_A);
// 2. Second request: pre-mint the cookies that match the test's
// expectation. The controller should recognise the pair is fresh
// and re-use it (meta tag carries the same token, no re-mint).
$_COOKIE['__csrf'] = $cookieA;
$_COOKIE['__csrf_token'] = $tokenA;
$this->primeGet('/');
$bodyB = $this->runFrontController();
$tokenB = $this->extractMetaToken($bodyB);
// Both bodies have valid tokens; the test's "no re-mint" claim is
// that $tokenA (the test's mint) is the same as $tokenB (the
// controller's reuse). The controller's mint and the test's mint
// are different only when the controller re-mints, so the
// identity proves the controller re-used the existing pair.
self::assertSame($tokenA, $tokenB, 'controller should re-use the existing token when cookies are valid');
}
public function testMissingTokenCookieIsFilledInOnNextRequest(): void
{
// 1. Boot under SECRET_A so the controller mints a pair.
putenv('BATTLEFORGE_SECRET=' . self::SECRET_A);
$this->primeGet('/');
$bodyA = $this->runFrontController();
$tokenA = $this->extractMetaToken($bodyA);
// 2. Browser has the __csrf cookie but is missing __csrf_token
// (a partial-clear scenario, e.g. user cleared just one).
putenv('BATTLEFORGE_SECRET=' . self::SECRET_B);
$_COOKIE['__csrf'] = hash_hmac('sha256', $tokenA, self::SECRET_A);
// __csrf_token is intentionally missing.
$this->primeGet('/');
$bodyB = $this->runFrontController();
$tokenB = $this->extractMetaToken($bodyB);
self::assertNotSame($tokenA, $tokenB, 'missing __csrf_token should trigger re-mint');
}
private function primeGet(string $path): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = $path;
$_SERVER['CONTENT_TYPE'] = null;
}
private function extractMetaToken(string $body): string
{
$regex = '//';
if (!preg_match($regex, $body, $m)) {
self::fail('response body did not contain a 64-char hex ; first 500 chars: ' . substr($body, 0, 500));
}
return $m[1];
}
private function runFrontController(): string
{
$this->lastStatus = 0;
$body = $this->captureOutput(function (): void {
$this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code();
});
return $body;
}
/** @param callable(): void $fn */
private function captureOutput(callable $fn): string
{
ob_start();
try {
$fn();
} finally {
$output = (string) ob_get_clean();
}
return $output;
}
}