mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-08 21:15:33 +09:00
* Harden guest session-state API calls against cross-site requests Extend the guest authentication guard to the session-bound cart mutations (cart add/remove item, promo apply/remove, reset, currency change), requiring the pre-login session nonce and a same-origin context like client login, signup, and staff login already do. Cart changes accept GET with the nonce in the query string, matching _checkCSRFToken()'s token sources, so shipped fb_api_link usage keeps working. Also pin an explicit SameSite=Lax on the session cookie when security.mode is not strict: an unset attribute leaves cookie behavior to each browser's default, and some browsers send cookies on cross-site requests when it is missing. The installer session already uses Lax. * Default queued cookies to SameSite Lax CookieQueue now defaults to Lax so locale and timezone preference cookies carry an explicit policy instead of leaving the behavior to each browser's unset-attribute default. The CSRF cookie keeps its explicit Strict, and callers can still pass null to omit the attribute. * Sign guest E2E requests with the pre-login nonce The Live Tests failures on the cart guard came from the test helper keeping its own copy of the guarded-endpoint list (login and signup only), so guest cart calls went out unsigned and were rejected with 403. Attach the nonce to every guest request instead - the dispatcher strips the parameter where it is not used - and let guest GET requests carry it in the query string, which _checkCSRFToken() already accepts. * Set the cURL URL after appending guest GET parameters CURLOPT_URL copies the value at set time, so mutating $url after curl_setopt_array() left the handle pointing at the query-less URL and guest GET requests went out without the nonce or parameters. Re-set CURLOPT_URL once the query string is appended.
315 lines
9.5 KiB
PHP
315 lines
9.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright 2022-2026 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Helpers;
|
|
|
|
use Symfony\Component\Filesystem\Filesystem;
|
|
use Symfony\Component\Filesystem\Path;
|
|
|
|
/**
|
|
* API client for E2E testing.
|
|
*/
|
|
class ApiClient
|
|
{
|
|
private static ?string $baseUrl = null;
|
|
private static ?string $apiKey = null;
|
|
private static string $cookiePath;
|
|
|
|
public static function setBaseUrl(string $url): void
|
|
{
|
|
self::$baseUrl = rtrim($url, '/');
|
|
}
|
|
|
|
public static function setApiKey(string $key): void
|
|
{
|
|
self::$apiKey = $key;
|
|
}
|
|
|
|
private static function getCookiePath(): string
|
|
{
|
|
if (!isset(self::$cookiePath)) {
|
|
self::$cookiePath = Path::join(sys_get_temp_dir(), 'fossbilling_e2e_cookies.txt');
|
|
}
|
|
|
|
return self::$cookiePath;
|
|
}
|
|
|
|
public static function request(
|
|
string $endpoint,
|
|
array $payload = [],
|
|
?string $role = null,
|
|
?string $apiKey = null,
|
|
string $method = 'POST',
|
|
): ApiResponse {
|
|
$baseUrl = self::$baseUrl ?? (getenv('APP_URL') ?: 'http://localhost');
|
|
$baseUrl = rtrim($baseUrl, '/');
|
|
$apiKey ??= self::$apiKey ?? getenv('TEST_API_KEY');
|
|
$role ??= str_starts_with($endpoint, 'admin') ? 'admin' : 'client';
|
|
|
|
$url = rtrim($baseUrl, '/') . '/api/' . ltrim($endpoint, '/');
|
|
|
|
// Guarded guest endpoints (login, signup, staff login, and the
|
|
// session-bound cart) require the pre-login nonce; the dispatcher strips
|
|
// the parameter for calls that ignore it, so attach it to every guest
|
|
// request rather than keep a second copy of the guard's endpoint list.
|
|
if (str_starts_with(trim($endpoint, '/'), 'guest/') && !isset($payload['CSRFToken'])) {
|
|
$payload['CSRFToken'] = self::getPreLoginToken($baseUrl);
|
|
}
|
|
|
|
$ch = curl_init();
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => $url,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
|
CURLOPT_COOKIEJAR => self::getCookiePath(),
|
|
CURLOPT_COOKIEFILE => self::getCookiePath(),
|
|
CURLOPT_USERPWD => "$role:$apiKey",
|
|
]);
|
|
|
|
if (strcasecmp($method, 'POST') === 0) {
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload));
|
|
} elseif (str_starts_with(trim($endpoint, '/'), 'guest/') && $payload !== []) {
|
|
// GET requests carrying a nonce (e.g. guarded cart mutations) need
|
|
// it in the query string; the payload is otherwise unused on GET.
|
|
$url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($payload);
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
}
|
|
|
|
$output = curl_exec($ch);
|
|
if ($output === false) {
|
|
$error = curl_error($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
throw new \RuntimeException('cURL request failed: ' . $error . ' (HTTP ' . $httpCode . ')');
|
|
}
|
|
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
return new ApiResponse($httpCode, $output);
|
|
}
|
|
|
|
private static function getPreLoginToken(string $baseUrl): string
|
|
{
|
|
$html = self::fetchLoginPage($baseUrl);
|
|
|
|
// Prefer the double-submit cookie: it mirrors the session token
|
|
// (FOSSBilling\Http\CookieNames::CSRF, set on every client page
|
|
// render) and stays valid across session-id rotation. Unlike the
|
|
// login form markup, it is also available when /login redirects
|
|
// because the jar's session is already authenticated, so back to
|
|
// back signup calls stay on the same session (preserving e.g. the
|
|
// guest cart).
|
|
$token = self::readCookieJarValue('fossbilling_csrf');
|
|
if (is_string($token) && $token !== '') {
|
|
return $token;
|
|
}
|
|
|
|
if (is_string($html) && preg_match('/name="CSRFToken" value="([^"]+)"/', $html, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
throw new \RuntimeException('Could not obtain the pre-login CSRF token');
|
|
}
|
|
|
|
private static function fetchLoginPage(string $baseUrl): mixed
|
|
{
|
|
$ch = curl_init($baseUrl . '/login');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_COOKIEJAR => self::getCookiePath(),
|
|
CURLOPT_COOKIEFILE => self::getCookiePath(),
|
|
]);
|
|
|
|
try {
|
|
return curl_exec($ch);
|
|
} finally {
|
|
// Flush the anonymous session cookie before the following API request.
|
|
curl_close($ch);
|
|
}
|
|
}
|
|
|
|
private static function readCookieJarValue(string $name): ?string
|
|
{
|
|
$path = self::getCookiePath();
|
|
if (!is_file($path)) {
|
|
return null;
|
|
}
|
|
|
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if ($lines === false) {
|
|
return null;
|
|
}
|
|
|
|
// The jar may hold several generations of cookies; the last non-empty match wins.
|
|
$value = null;
|
|
foreach ($lines as $line) {
|
|
if (str_starts_with($line, '#') && !str_starts_with($line, '#HttpOnly.')) {
|
|
continue;
|
|
}
|
|
$parts = explode("\t", (string) preg_replace('/^#HttpOnly\./', '', $line));
|
|
if (count($parts) === 7 && $parts[5] === $name && $parts[6] !== '') {
|
|
$value = $parts[6];
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
public static function resetCookies(): void
|
|
{
|
|
$filesystem = new Filesystem();
|
|
$filesystem->remove(self::getCookiePath());
|
|
}
|
|
|
|
public static function get(string $endpoint, array $payload = [], ?string $role = null): ApiResponse
|
|
{
|
|
return self::request($endpoint, $payload, $role ?? 'admin', null, 'GET');
|
|
}
|
|
|
|
public static function post(string $endpoint, array $payload = [], ?string $role = null): ApiResponse
|
|
{
|
|
return self::request($endpoint, $payload, $role ?? 'admin', null, 'POST');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* API Response wrapper for E2E tests.
|
|
*/
|
|
class ApiResponse
|
|
{
|
|
private array $decodedResponse;
|
|
|
|
public function __construct(
|
|
private readonly int $code,
|
|
private readonly string $rawResponse,
|
|
) {
|
|
$decoded = json_decode($this->rawResponse, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
|
$preview = strlen($this->rawResponse) > 500 ? substr($this->rawResponse, 0, 500) . '...' : $this->rawResponse;
|
|
|
|
throw new \RuntimeException('Invalid JSON response: ' . json_last_error_msg() . ' (HTTP ' . $this->code . '). Response: ' . $preview);
|
|
}
|
|
$this->decodedResponse = $decoded;
|
|
}
|
|
|
|
public function getHttpCode(): int
|
|
{
|
|
return $this->getStatusCode();
|
|
}
|
|
|
|
public function getStatusCode(): int
|
|
{
|
|
return $this->code;
|
|
}
|
|
|
|
public function getResponse(): array
|
|
{
|
|
return $this->decodedResponse;
|
|
}
|
|
|
|
public function getRawResponse(): string
|
|
{
|
|
return $this->rawResponse;
|
|
}
|
|
|
|
public function wasSuccessful(): bool
|
|
{
|
|
return !empty($this->decodedResponse)
|
|
&& array_key_exists('error', $this->decodedResponse)
|
|
&& empty($this->decodedResponse['error'])
|
|
&& array_key_exists('result', $this->decodedResponse);
|
|
}
|
|
|
|
public function getErrorMessage(): string
|
|
{
|
|
return $this->decodedResponse['error']['message'] ?? 'None';
|
|
}
|
|
|
|
public function getErrorCode(): int
|
|
{
|
|
return (int) ($this->decodedResponse['error']['code'] ?? 0);
|
|
}
|
|
|
|
public function getError(): string
|
|
{
|
|
return $this->getErrorMessage() . ' (Code ' . $this->getErrorCode() . ')';
|
|
}
|
|
|
|
public function getResult(): mixed
|
|
{
|
|
return $this->decodedResponse['result'] ?? null;
|
|
}
|
|
|
|
public function hasResult(): bool
|
|
{
|
|
return array_key_exists('result', $this->decodedResponse);
|
|
}
|
|
|
|
public function isArrayResult(): bool
|
|
{
|
|
return is_array($this->decodedResponse['result'] ?? null);
|
|
}
|
|
|
|
public function isIntResult(): bool
|
|
{
|
|
return is_int($this->decodedResponse['result'] ?? null);
|
|
}
|
|
|
|
public function isBoolResult(): bool
|
|
{
|
|
return is_bool($this->decodedResponse['result'] ?? null);
|
|
}
|
|
|
|
public function isStringResult(): bool
|
|
{
|
|
return is_string($this->decodedResponse['result'] ?? null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function for API requests.
|
|
*/
|
|
function api(string $endpoint, array $payload = [], ?string $role = null): ApiResponse
|
|
{
|
|
return ApiClient::request($endpoint, $payload, $role);
|
|
}
|
|
|
|
/**
|
|
* Assert that an API response was successful.
|
|
*/
|
|
function assertApiSuccess(ApiResponse $response): void
|
|
{
|
|
expect($response->wasSuccessful())
|
|
->toBeTrue($response->getError());
|
|
}
|
|
|
|
/**
|
|
* Assert that an API response has an integer result.
|
|
*/
|
|
function assertApiResultIsInt(ApiResponse $response): void
|
|
{
|
|
expect($response->isIntResult())
|
|
->toBeTrue('Expected integer result, got: ' . gettype($response->getResult()));
|
|
}
|
|
|
|
/**
|
|
* Assert that an API response has an array result.
|
|
*/
|
|
function assertApiResultIsArray(ApiResponse $response): void
|
|
{
|
|
expect($response->isArrayResult())
|
|
->toBeTrue('Expected array result, got: ' . gettype($response->getResult()));
|
|
}
|