mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
569 lines
18 KiB
PHP
569 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\E2E\Scopes;
|
|
|
|
use Appwrite\Tests\Async;
|
|
use Appwrite\Tests\Retryable;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Tests\E2E\Client;
|
|
use Utopia\Database\Helpers\ID;
|
|
use Utopia\System\System;
|
|
|
|
abstract class Scope extends TestCase
|
|
{
|
|
use Retryable;
|
|
use Async;
|
|
|
|
public const REQUEST_TYPE_WEBHOOK = 'webhook';
|
|
public const REQUEST_TYPE_SMS = 'sms';
|
|
|
|
protected ?Client $client = null;
|
|
protected string $endpoint = 'http://appwrite/v1';
|
|
protected string $webEndpoint = 'http://appwrite.test/v1';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->client = new Client();
|
|
$this->client->setEndpoint($this->endpoint);
|
|
|
|
$format = System::getEnv('_APP_E2E_RESPONSE_FORMAT');
|
|
if (!empty($format)) {
|
|
if (
|
|
!\preg_match('/^\d+\.\d+\.\d+$/', $format) ||
|
|
!\version_compare($format, APP_VERSION_STABLE, '<=')
|
|
) {
|
|
throw new \Exception('E2E response format must be ' . APP_VERSION_STABLE . ' or lower.');
|
|
}
|
|
$this->client->setResponseFormat($format);
|
|
}
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->client = null;
|
|
}
|
|
|
|
/**
|
|
* @var array|null Cached console variables
|
|
*/
|
|
protected static ?array $consoleVariables = null;
|
|
|
|
/**
|
|
* Fetch console variables from the API
|
|
*/
|
|
protected function getConsoleVariables(): array
|
|
{
|
|
if (self::$consoleVariables !== null) {
|
|
return self::$consoleVariables;
|
|
}
|
|
|
|
$root = $this->getRoot();
|
|
|
|
for ($i = 0; $i < 3; $i++) {
|
|
$response = $this->client->call(Client::METHOD_GET, '/console/variables', [
|
|
'origin' => 'http://localhost',
|
|
'content-type' => 'application/json',
|
|
'x-appwrite-project' => 'console',
|
|
'cookie' => 'a_session_console=' . $root['session'],
|
|
]);
|
|
|
|
if ($response['headers']['status-code'] === 200 && !empty($response['body'])) {
|
|
self::$consoleVariables = $response['body'];
|
|
return self::$consoleVariables;
|
|
}
|
|
|
|
\usleep(500000);
|
|
}
|
|
|
|
self::$consoleVariables = $response['body'] ?? [];
|
|
|
|
return self::$consoleVariables;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports relationships
|
|
*/
|
|
protected function getSupportForRelationships(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForRelationships'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports operators
|
|
*/
|
|
protected function getSupportForOperators(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForOperators'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports spatial attributes
|
|
*/
|
|
protected function getSupportForSpatials(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForSpatials'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports spatial indexes on nullable columns
|
|
*/
|
|
protected function getSupportForSpatialIndexNull(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForSpatialIndexNull'] ?? false;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports fulltext wildcard search
|
|
*/
|
|
protected function getSupportForFulltextWildcard(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForFulltextWildcard'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports multiple fulltext indexes per collection
|
|
*/
|
|
protected function getSupportForMultipleFulltextIndexes(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForMultipleFulltextIndexes'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports resizing attributes
|
|
*/
|
|
protected function getSupportForAttributeResizing(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForAttributeResizing'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports fixed schemas with row width limits
|
|
*/
|
|
protected function getSupportForSchemas(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForSchemas'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter supports attributes
|
|
*/
|
|
protected function getSupportForAttributes(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForAttributes'] ?? true;
|
|
}
|
|
|
|
/**
|
|
* Get the maximum index length supported by the database adapter
|
|
*/
|
|
protected function getMaxIndexLength(): int
|
|
{
|
|
return $this->getConsoleVariables()['maxIndexLength'] ?? 767;
|
|
}
|
|
|
|
/**
|
|
* Check if the database adapter uses integer sequence IDs
|
|
*/
|
|
protected function getSupportForIntegerIds(): bool
|
|
{
|
|
return $this->getConsoleVariables()['supportForIntegerIds'] ?? true;
|
|
}
|
|
|
|
protected function getLastEmail(int $limit = 1, ?callable $probe = null): array
|
|
{
|
|
$result = [];
|
|
$this->assertEventually(function () use (&$result, $limit, $probe) {
|
|
$emails = json_decode(file_get_contents('http://maildev:1080/email'), true);
|
|
|
|
$this->assertNotEmpty($emails, 'Maildev should have at least one email');
|
|
$this->assertIsArray($emails);
|
|
|
|
if ($probe !== null && $limit === 1) {
|
|
for ($i = count($emails) - 1; $i >= 0; $i--) {
|
|
try {
|
|
$probe($emails[$i]);
|
|
$result = $emails[$i];
|
|
return;
|
|
} catch (\Throwable) {
|
|
continue;
|
|
}
|
|
}
|
|
$this->fail('No email matching probe found');
|
|
} elseif ($limit === 1) {
|
|
$result = end($emails);
|
|
} else {
|
|
$result = array_slice($emails, -1 * $limit);
|
|
$this->assertCount($limit, $result, "Expected {$limit} emails but only got " . count($result));
|
|
}
|
|
|
|
$this->assertNotEmpty($result, 'Expected email result to be non-empty');
|
|
}, 15_000, 500);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Get the last email sent to a specific address.
|
|
* This is more reliable than getLastEmail() when tests run in parallel.
|
|
*/
|
|
protected function getLastEmailByAddress(string $address, ?callable $probe = null): array
|
|
{
|
|
$result = [];
|
|
$this->assertEventually(function () use (&$result, $address, $probe) {
|
|
$emails = json_decode(file_get_contents('http://maildev:1080/email'), true);
|
|
|
|
$this->assertNotEmpty($emails, 'Maildev should have at least one email');
|
|
$this->assertIsArray($emails);
|
|
|
|
// Search from the end (most recent) to the beginning
|
|
for ($i = count($emails) - 1; $i >= 0; $i--) {
|
|
$email = $emails[$i];
|
|
if (isset($email['to']) && is_array($email['to'])) {
|
|
foreach ($email['to'] as $recipient) {
|
|
if (isset($recipient['address']) && $recipient['address'] === $address) {
|
|
if ($probe !== null) {
|
|
try {
|
|
$probe($email);
|
|
} catch (\Throwable) {
|
|
continue 2;
|
|
}
|
|
}
|
|
$result = $email;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->fail("No email found for address: {$address}" . ($probe !== null ? ' matching probe' : ''));
|
|
}, 15_000, 500);
|
|
|
|
return $result;
|
|
}
|
|
|
|
protected function extractQueryParamsFromEmailLink(string $html): array
|
|
{
|
|
foreach (['/join-us?', '/verification?', '/recovery?'] as $prefix) {
|
|
$linkStart = strpos($html, $prefix);
|
|
if ($linkStart !== false) {
|
|
$hrefStart = strrpos(substr($html, 0, $linkStart), 'href="');
|
|
if ($hrefStart === false) {
|
|
continue;
|
|
}
|
|
|
|
$hrefStart += 6;
|
|
$hrefEnd = strpos($html, '"', $hrefStart);
|
|
if ($hrefEnd === false || $hrefStart >= $hrefEnd) {
|
|
continue;
|
|
}
|
|
|
|
$link = substr($html, $hrefStart, $hrefEnd - $hrefStart);
|
|
$link = strtok($link, '#'); // Remove `#title`
|
|
$queryStart = strpos($link, '?');
|
|
if ($queryStart === false) {
|
|
continue;
|
|
}
|
|
|
|
$queryString = substr($link, $queryStart + 1);
|
|
parse_str(html_entity_decode($queryString), $queryParams);
|
|
return $queryParams;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
protected function assertLastRequest(callable $probe, string $type, $timeoutMs = 20_000, $waitMs = 500): array
|
|
{
|
|
$hostname = match ($type) {
|
|
'webhook' => 'request-catcher-webhook',
|
|
'sms' => 'request-catcher-sms',
|
|
default => throw new \Exception('Invalid request catcher type.'),
|
|
};
|
|
|
|
$this->assertEventually(function () use (&$request, $probe, $hostname) {
|
|
$request = json_decode(file_get_contents('http://' . $hostname . ':5000/__last_request__'), true);
|
|
$request['data'] = json_decode($request['data'], true);
|
|
|
|
call_user_func($probe, $request);
|
|
}, $timeoutMs, $waitMs);
|
|
|
|
return $request;
|
|
}
|
|
|
|
protected function assertSamePixels(string $expectedImagePath, string $actualImageBlob): void
|
|
{
|
|
$expected = new \Imagick($expectedImagePath);
|
|
$actual = new \Imagick();
|
|
$actual->readImageBlob($actualImageBlob);
|
|
|
|
foreach ([$expected, $actual] as $image) {
|
|
$image->setImageFormat('PNG');
|
|
$image->stripImage();
|
|
$image->setOption('png:exclude-chunks', 'date,time,iCCP,sRGB,gAMA,cHRM');
|
|
}
|
|
|
|
$this->assertSame($expected->getImageSignature(), $actual->getImageSignature());
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use getLastRequestForProject instead. Used only historically in webhook tests
|
|
*/
|
|
protected function getLastRequest(?callable $probe = null): array
|
|
{
|
|
$project = $this->getProject();
|
|
$this->assertArrayHasKey('$id', $project, 'Project must have an $id');
|
|
return $this->getLastRequestForProject($project['$id'], self::REQUEST_TYPE_WEBHOOK, [], 10, 500, $probe);
|
|
}
|
|
|
|
/**
|
|
* Get the last webhook request for a specific project.
|
|
* Polls with retry to handle parallel test race conditions.
|
|
*/
|
|
protected function getLastRequestForProject(
|
|
string $projectId,
|
|
string $type = self::REQUEST_TYPE_WEBHOOK,
|
|
array $queryParams = [],
|
|
int $maxAttempts = 10,
|
|
int $delayMs = 500,
|
|
?callable $probe = null
|
|
): array {
|
|
$hostname = match ($type) {
|
|
self::REQUEST_TYPE_WEBHOOK => 'request-catcher-webhook',
|
|
self::REQUEST_TYPE_SMS => 'request-catcher-sms',
|
|
default => throw new \Exception('Invalid request catcher type.'),
|
|
};
|
|
$enforceProjectId = $type === self::REQUEST_TYPE_WEBHOOK;
|
|
|
|
if (empty($queryParams)) {
|
|
$queryParams = [
|
|
'header_X-Appwrite-Webhook-Project-Id' => $projectId,
|
|
];
|
|
}
|
|
|
|
$query = http_build_query($queryParams);
|
|
|
|
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
|
$requests = json_decode(file_get_contents('http://' . $hostname . ':5000/__find_request__?' . $query), true);
|
|
if (is_array($requests)) {
|
|
for ($i = count($requests) - 1; $i >= 0; $i--) {
|
|
$request = $this->decodeRequestData($requests[$i]);
|
|
if ($probe !== null) {
|
|
try {
|
|
$probe($request);
|
|
return $request;
|
|
} catch (\Throwable $error) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if ($enforceProjectId) {
|
|
$requestProjectId = $request['headers']['X-Appwrite-Webhook-Project-Id'] ?? '';
|
|
if ($requestProjectId === $projectId) {
|
|
return $request;
|
|
}
|
|
} else {
|
|
return $request;
|
|
}
|
|
}
|
|
}
|
|
|
|
usleep($delayMs * 1000);
|
|
}
|
|
|
|
$requests = json_decode(file_get_contents('http://' . $hostname . ':5000/__find_request__?' . $query), true);
|
|
if (is_array($requests)) {
|
|
for ($i = count($requests) - 1; $i >= 0; $i--) {
|
|
$request = $this->decodeRequestData($requests[$i]);
|
|
if ($probe !== null) {
|
|
try {
|
|
$probe($request);
|
|
return $request;
|
|
} catch (\Throwable $error) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if ($enforceProjectId) {
|
|
$requestProjectId = $request['headers']['X-Appwrite-Webhook-Project-Id'] ?? '';
|
|
if ($requestProjectId === $projectId) {
|
|
return $request;
|
|
}
|
|
} else {
|
|
return $request;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
protected function decodeRequestData(array $request): array
|
|
{
|
|
if (!array_key_exists('data', $request)) {
|
|
return $request;
|
|
}
|
|
|
|
if (is_array($request['data'])) {
|
|
return $request;
|
|
}
|
|
|
|
if (!is_string($request['data']) || $request['data'] === '') {
|
|
return $request;
|
|
}
|
|
|
|
$decoded = json_decode($request['data'], true);
|
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
$request['data'] = $decoded;
|
|
return $request;
|
|
}
|
|
|
|
parse_str($request['data'], $parsed);
|
|
if (!empty($parsed)) {
|
|
$request['data'] = $parsed;
|
|
}
|
|
|
|
return $request;
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
abstract public function getHeaders(bool $devKey = true): array;
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
abstract public function getProject(): array;
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected static $root = [];
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
public function getRoot(): array
|
|
{
|
|
if ((self::$root)) {
|
|
return self::$root;
|
|
}
|
|
|
|
$maxRetries = 5;
|
|
|
|
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
|
|
// Use more entropy to avoid collisions in parallel test execution
|
|
$email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test';
|
|
$password = 'password';
|
|
$name = 'User Name';
|
|
|
|
$root = $this->client->call(Client::METHOD_POST, '/account', [
|
|
'origin' => 'http://localhost',
|
|
'content-type' => 'application/json',
|
|
'x-appwrite-project' => 'console',
|
|
], [
|
|
'userId' => ID::unique(),
|
|
'email' => $email,
|
|
'password' => $password,
|
|
'name' => $name,
|
|
]);
|
|
|
|
if ($root['headers']['status-code'] !== 201) {
|
|
\usleep(500000);
|
|
continue;
|
|
}
|
|
|
|
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
|
|
'origin' => 'http://localhost',
|
|
'content-type' => 'application/json',
|
|
'x-appwrite-project' => 'console',
|
|
], [
|
|
'email' => $email,
|
|
'password' => $password,
|
|
]);
|
|
|
|
if (empty($session['cookies']['a_session_console'])) {
|
|
\usleep(500000);
|
|
continue;
|
|
}
|
|
|
|
// Verify session is valid before returning
|
|
$verify = $this->client->call(Client::METHOD_GET, '/account', [
|
|
'origin' => 'http://localhost',
|
|
'content-type' => 'application/json',
|
|
'cookie' => 'a_session_console=' . $session['cookies']['a_session_console'],
|
|
'x-appwrite-project' => 'console',
|
|
]);
|
|
|
|
if ($verify['headers']['status-code'] === 200) {
|
|
self::$root = [
|
|
'$id' => ID::custom($root['body']['$id']),
|
|
'name' => $root['body']['name'],
|
|
'email' => $root['body']['email'],
|
|
'session' => $session['cookies']['a_session_console'],
|
|
];
|
|
|
|
return self::$root;
|
|
}
|
|
|
|
\usleep(500000);
|
|
}
|
|
|
|
$this->fail('Failed to create and verify root session after ' . $maxRetries . ' attempts');
|
|
}
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected static $user = [];
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
public function getUser(bool $fresh = false): array
|
|
{
|
|
$projectId = $this->getProject()['$id'];
|
|
|
|
if (!$fresh && isset(self::$user[$projectId])) {
|
|
return self::$user[$projectId];
|
|
}
|
|
|
|
// Use more entropy to avoid collisions in parallel test execution
|
|
$email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test';
|
|
$password = 'password';
|
|
$name = 'User Name';
|
|
|
|
$user = $this->client->call(Client::METHOD_POST, '/account', [
|
|
'origin' => 'http://localhost',
|
|
'content-type' => 'application/json',
|
|
'x-appwrite-project' => $projectId,
|
|
], [
|
|
'userId' => ID::unique(),
|
|
'email' => $email,
|
|
'password' => $password,
|
|
'name' => $name,
|
|
]);
|
|
|
|
$this->assertEquals(201, $user['headers']['status-code']);
|
|
|
|
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
|
|
'origin' => 'http://localhost',
|
|
'content-type' => 'application/json',
|
|
'x-appwrite-project' => $projectId,
|
|
], [
|
|
'email' => $email,
|
|
'password' => $password,
|
|
]);
|
|
|
|
self::$user[$projectId] = [
|
|
'$id' => ID::custom($user['body']['$id']),
|
|
'name' => $user['body']['name'],
|
|
'email' => $user['body']['email'],
|
|
'session' => $session['cookies']['a_session_' . $projectId],
|
|
'sessionId' => $session['body']['$id'],
|
|
];
|
|
|
|
return self::$user[$projectId];
|
|
}
|
|
}
|