diff --git a/phpstan.neon b/phpstan.neon index b2cb0c04..9200ea0f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,6 @@ parameters: - src - test level: 9 + phpVersion: 80200 inferPrivatePropertyTypeFromConstructor: true checkGenericClassInNonGenericObjectType: true diff --git a/src/Plugin/Auth/GrantRevokeHandler.php b/src/Plugin/Auth/GrantRevokeHandler.php new file mode 100644 index 00000000..4c4c0ce8 --- /dev/null +++ b/src/Plugin/Auth/GrantRevokeHandler.php @@ -0,0 +1,189 @@ + $this->processRequest(), + [$this->payload, $this->manticoreClient] + )->run(); + } + + /** + * Process the GRANT or REVOKE request + * + * @return TaskResult + * @throws GenericError + */ + private function processRequest(): TaskResult { + assert($this->payload->username !== null); + assert($this->payload->action !== null); + assert($this->payload->target !== null); + $username = addslashes($this->payload->username); + $action = addslashes($this->payload->action); + $target = addslashes($this->payload->target); + $budget = $this->payload->budget !== null ? addslashes($this->payload->budget) : '{}'; + + if ($this->payload->type === 'grant') { + return $this->handleGrant($username, $action, $target, $budget); + } + + if ($this->payload->type === 'revoke') { + return $this->handleRevoke($username, $action, $target); + } + + throw GenericError::create('Invalid operation type for GrantRevokeHandler.'); + } + + /** + * Check if the user exists in the users table + * + * @param string $username The username to check + * @return bool + * @throws GenericError + */ + private function userExists(string $username): bool { + $tableUsers = Payload::AUTH_USERS_TABLE; + $query = "SELECT count(*) as c FROM {$tableUsers} WHERE username = '{$username}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + $result = $resp->getResult()->toArray(); + if (!is_array($result) || !isset($result[0]['data'][0]['c'])) { + throw GenericError::create('Unexpected response format when checking user existence.'); + } + + $count = (int)$result[0]['data'][0]['c']; + return $count > 0; + } + + /** + * Check if a permission already exists for the user + * + * @param string $username The username to check + * @param string $action The action to check + * @param string $target The target to check + * @return bool + * @throws GenericError + */ + private function permissionExists(string $username, string $action, string $target): bool { + $tablePerms = Payload::AUTH_PERMISSIONS_TABLE; + $query = "SELECT count(*) as c FROM {$tablePerms} WHERE ". + "username = '{$username}' AND action = '{$action}' AND target = '{$target}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + $result = $resp->getResult()->toArray(); + if (!is_array($result) || !isset($result[0]['data'][0]['c'])) { + throw GenericError::create('Unexpected response format when checking permission existence.'); + } + + $count = (int)$result[0]['data'][0]['c']; + return $count > 0; + } + + /** + * Handle GRANT command by adding a permission + * + * @param string $username The username to grant permissions to + * @param string $action The action to grant (e.g., read, write) + * @param string $target The target (e.g., *, table/mytable) + * @param string $budget JSON-encoded budget or '{}' + * @return TaskResult + * @throws GenericError + */ + private function handleGrant(string $username, string $action, string $target, string $budget): TaskResult { + if (!$this->userExists($username)) { + throw GenericError::create("User '{$username}' does not exist."); + } + + // Check if permission already exists + if ($this->permissionExists($username, $action, $target)) { + throw GenericError::create("User '{$username}' already has '{$action}' permission on '{$target}'."); + } + + $tablePerms = Payload::AUTH_PERMISSIONS_TABLE; + $query = "INSERT INTO {$tablePerms} (username, action, target, allow, budget) " . + "VALUES ('{$username}', '{$action}', '{$target}', 1, '{$budget}')"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + return TaskResult::none(); + } + + /** + * Handle REVOKE command by removing a permission + * + * @param string $username The username to revoke permissions from + * @param string $action The action to revoke (e.g., read, write) + * @param string $target The target (e.g., *, table/mytable) + * @return TaskResult + * @throws GenericError + */ + private function handleRevoke(string $username, string $action, string $target): TaskResult { + if (!$this->userExists($username)) { + throw GenericError::create("User '{$username}' does not exist."); + } + + // Check if permission exists before revoking + if (!$this->permissionExists($username, $action, $target)) { + throw GenericError::create("User '{$username}' does not have '{$action}' permission on '{$target}'."); + } + + $tablePerms = Payload::AUTH_PERMISSIONS_TABLE; + $query = "DELETE FROM {$tablePerms} WHERE username = '{$username}' ". + "AND action = '{$action}' AND target = '{$target}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + return TaskResult::none(); + } +} diff --git a/src/Plugin/Auth/HashGeneratorTrait.php b/src/Plugin/Auth/HashGeneratorTrait.php new file mode 100644 index 00000000..6e12df41 --- /dev/null +++ b/src/Plugin/Auth/HashGeneratorTrait.php @@ -0,0 +1,101 @@ + sha1($password), + self::PASSWORD_SHA256_KEY => hash('sha256', $salt . $password), + self::BEARER_SHA256_KEY => $this->generateTokenHash($token, $salt), + ]; + $hashesJson = json_encode($hashes); + + if ($hashesJson === false) { + throw GenericError::create('Failed to encode hashes as JSON.'); + } + + return addslashes($hashesJson); + } + + /** + * Update password hashes while preserving existing bearer_sha256 + * + * @param string $newPassword The new password to hash + * @param string $salt The salt to use for hashing + * @param array $existingHashes The existing hashes array to preserve bearer_sha256 + * @return string JSON-encoded updated hashes + * @throws GenericError + */ + private function updatePasswordHashes(string $newPassword, string $salt, array $existingHashes): string { + if (empty($existingHashes[self::BEARER_SHA256_KEY])) { + throw GenericError::create('Existing bearer_sha256 hash is required for password update.'); + } + + $updatedHashes = [ + self::PASSWORD_SHA1_KEY => sha1($newPassword), + self::PASSWORD_SHA256_KEY => hash('sha256', $salt . $newPassword), + self::BEARER_SHA256_KEY => $existingHashes[self::BEARER_SHA256_KEY], // Preserve existing token hash + ]; + $hashesJson = json_encode($updatedHashes); + + if ($hashesJson === false) { + throw GenericError::create('Failed to encode updated hashes as JSON.'); + } + + return addslashes($hashesJson); + } + + /** + * Validate hash structure + * + * @param array $hashes The hashes array to validate + * @throws GenericError + */ + private function validateHashesStructure(array $hashes): void { + $required = [self::PASSWORD_SHA1_KEY, self::PASSWORD_SHA256_KEY, self::BEARER_SHA256_KEY]; + foreach ($required as $key) { + if (!isset($hashes[$key]) || !is_string($hashes[$key])) { + throw GenericError::create("Invalid hash structure: missing or invalid '{$key}'."); + } + } + } + + /** + * Generate token hash for bearer authentication + * + * @param string $token The token to hash + * @param string $salt The salt to use for hashing + * @return string The token hash + */ + private function generateTokenHash(string $token, string $salt): string { + return hash('sha256', $salt . hash('sha256', $token)); + } +} diff --git a/src/Plugin/Auth/PasswordHandler.php b/src/Plugin/Auth/PasswordHandler.php new file mode 100644 index 00000000..ed66fa77 --- /dev/null +++ b/src/Plugin/Auth/PasswordHandler.php @@ -0,0 +1,150 @@ + $this->processRequest(), + [$this->payload, $this->manticoreClient] + )->run(); + } + + /** + * Validate password strength and constraints + * + * @param string $password The password to validate + * @throws GenericError + */ + private function validatePassword(string $password): void { + if (empty($password)) { + throw GenericError::create('Password cannot be empty.'); + } + if (strlen($password) < 8) { + throw GenericError::create('Password must be at least 8 characters long.'); + } + if (strlen($password) > 128) { + throw GenericError::create('Password is too long (max 128 characters).'); + } + } + + /** + * Process the password update request + * + * @return TaskResult + * @throws GenericError + */ + private function processRequest(): TaskResult { + $username = $this->payload->username ?? $this->payload->actingUser; + $username = addslashes($username); + $password = $this->payload->password; + + if ($password === null) { + throw GenericError::create('Password is required for password update.'); + } + + $this->validatePassword($password); + + $userData = $this->getUserData($username); + $existingHashes = json_decode($userData['hashes'], true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw GenericError::create('Failed to parse user hash data: ' . json_last_error_msg()); + } + + if (!is_array($existingHashes)) { + throw GenericError::create('Invalid hash data format: expected array.'); + } + + // Validate hash structure before proceeding + $this->validateHashesStructure($existingHashes); + + // Update only password hashes, preserve bearer_sha256 + $hashesJson = $this->updatePasswordHashes($password, $userData['salt'], $existingHashes); + $this->replaceUserData($username, $userData['salt'], $hashesJson); + + return TaskResult::none(); + } + + /** + * Fetch current user data (salt and hashes) + * + * @param string $username The username to fetch data for + * @return array{salt: string, hashes: string} + * @throws GenericError + */ + private function getUserData(string $username): array { + $tableUsers = Payload::AUTH_USERS_TABLE; + $query = "SELECT salt, hashes FROM {$tableUsers} WHERE username = '{$username}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + $result = $resp->getResult()->toArray(); + if (!is_array($result) || !isset($result[0]['data'][0])) { + throw GenericError::create("User '{$username}' does not exist."); + } + + $userData = $result[0]['data'][0]; + if (!is_array($userData) || !isset($userData['salt']) || !isset($userData['hashes'])) { + throw GenericError::create('Invalid user data format.'); + } + + return $userData; + } + + /** + * Replace user data in the users table with new hashes + * + * @param string $username The username to update + * @param string $salt The existing salt + * @param string $hashesJson The new hashes JSON + * @throws GenericError + */ + private function replaceUserData(string $username, string $salt, string $hashesJson): void { + $tableUsers = Payload::AUTH_USERS_TABLE; + $query = "REPLACE INTO {$tableUsers} (username, salt, hashes) ". + "VALUES ('{$username}', '{$salt}', '{$hashesJson}')"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + } +} diff --git a/src/Plugin/Auth/Payload.php b/src/Plugin/Auth/Payload.php new file mode 100644 index 00000000..261c5cf0 --- /dev/null +++ b/src/Plugin/Auth/Payload.php @@ -0,0 +1,288 @@ + + */ +final class Payload extends BasePayload { + + const AUTH_USERS_TABLE = 'system.auth_users'; + const AUTH_PERMISSIONS_TABLE = 'system.auth_permissions'; + + public string $type; + private string $handler; + + public ?string $username = null; + public ?string $password = null; + public string $target; + + public string $actingUser; + public string $action; + public ?string $budget = null; + + /** + * Get a description for this plugin + * @return string + */ + public static function getInfo(): string { + /** + * Handle commands: + * + * CREATE USER '' IDENTIFIED BY '' + * + * DROP USER '' + * + * GRANT ON TO '' [WITH BUDGET ]; + * GRANT READ ON * TO 'readonly' WITH BUDGET '{"queries_per_day": 10000}'; + * GRANT WRITE ON 'mytable' TO 'custom_user'; + * + * REVOKE ON FROM ''; + * REVOKE READ ON '*' FROM 'readonly'; + * + * SHOW USERS + * SHOW MY PERMISSIONS; + * SHOW PERMISSIONS; + * + * SET PASSWORD 'abcdef'; + * SET PASSWORD 'abcdef' FOR 'justin'; + * + */ + return 'Handles commands related to Authentication'; + } + + /** + * @param Request $request + * @return static + * @throws GenericError + */ + public static function fromRequest(Request $request): static { + $self = new static(); + $self->actingUser = $request->user; + + [$self->type, $self->handler] = match (true) { + self::hasCreateUser($request) => ['create', 'UserHandler'], + self::hasDropUser($request) => ['drop', 'UserHandler'], + self::hasGrant($request) => ['grant', 'GrantRevokeHandler'], + self::hasRevoke($request) => ['revoke', 'GrantRevokeHandler'], + self::hasShowMyPermissions($request) => ['show_my_permissions', 'ShowHandler'], + self::hasSetPassword($request) => ['set_password', 'PasswordHandler'], + default => throw GenericError::create('Failed to handle your query', true) + }; + + switch ($self->handler) { + case 'UserHandler': + self::parseUserCommand($request->payload, $self); + break; + case 'GrantRevokeHandler': + self::parseGrantRevokeCommand($request->payload, $self); + break; + case 'ShowHandler': + break; + case 'PasswordHandler': + self::parsePasswordCommand($request->payload, $self); + break; + } + + return $self; + } + + /** + * @param Request $request + * @return bool + */ + public static function hasMatch(Request $request): bool { + return ( + self::hasCreateUser($request) || + self::hasDropUser($request) || + self::hasGrant($request) || + self::hasRevoke($request) || + self::hasShowMyPermissions($request) || + self::hasSetPassword($request) + ); + } + + /** + * @param Request $request + * @return bool + */ + private static function hasCreateUser(Request $request): bool { + return (str_starts_with( + $request->error, + 'P03: syntax error, unexpected tablename, '. + "expecting CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER" + ) + && stripos($request->payload, 'CREATE USER') !== false); + } + + /** + * @param Request $request + * @return bool + */ + private static function hasDropUser(Request $request): bool { + // More robust pattern matching for DROP USER detection + $patterns = [ + 'P03: syntax error, unexpected tablename, expecting FUNCTION or PLUGIN or TABLE near', + 'P03: syntax error, unexpected identifier near', + ]; + + $matchesPattern = false; + foreach ($patterns as $pattern) { + if (str_starts_with($request->error, $pattern)) { + $matchesPattern = true; + break; + } + } + + return $matchesPattern && stripos($request->payload, 'DROP USER') !== false; + } + + /** + * @param Request $request + * @return bool + */ + private static function hasGrant(Request $request): bool { + return (str_starts_with( + $request->error, + "P02: syntax error, unexpected identifier near 'GRANT" + ) + && stripos($request->payload, 'GRANT') !== false); + } + + /** + * @param Request $request + * @return bool + */ + private static function hasRevoke(Request $request): bool { + return (str_starts_with( + $request->error, + "P02: syntax error, unexpected identifier near 'REVOKE" + ) + && stripos($request->payload, 'REVOKE') !== false); + } + + /** + * @param Request $request + * @return bool + */ + private static function hasShowMyPermissions(Request $request): bool { + return (str_starts_with( + $request->error, + 'P01: syntax error, unexpected identifier, '. + "expecting VARIABLES near 'MY PERMISSIONS'" + ) + && stripos($request->payload, 'SHOW MY PERMISSIONS') !== false); + } + + /** + * @param Request $request + * @return bool + */ + private static function hasSetPassword(Request $request): bool { + return (str_starts_with( + $request->error, + 'P01: syntax error, unexpected string, '. + "expecting '=' near" + ) + && stripos($request->payload, 'SET PASSWORD') !== false); + } + + /** + * @throws GenericError + */ + private static function parseUserCommand(string $payload, self $self): void { + $createPattern = '/^CREATE\s+USER\s+\'([^\']+)\'\s+IDENTIFIED\s+BY\s+\'([^\']+)\'$/i'; + $dropPattern = '/^DROP\s+USER\s+\'([^\']+)\'$/i'; + + if (preg_match($createPattern, $payload, $matches)) { + $self->username = $matches[1]; + $self->password = $matches[2]; + } elseif (preg_match($dropPattern, $payload, $matches)) { + $self->username = $matches[1]; + $self->password = null; + } else { + throw GenericError::create('Invalid payload: Does not match CREATE USER or DROP USER command.', true); + } + } + + /** + * @throws GenericError + */ + private static function parseGrantRevokeCommand(string $payload, self $self): void { + $pattern = '/^(GRANT|REVOKE)\s+([\w]+)\s+ON\s+(\*|\'([^\']*)\')'. + '\s+(TO|FROM)\s+\'([^\']+)\'(?:\s+WITH\s+BUDGET\s+([\'"]?\{.*?\}[\'"]?))?$/i'; + + if (!preg_match($pattern, $payload, $matches)) { + throw GenericError::create('Invalid payload: Does not match GRANT or REVOKE command.', true); + } + + $command = strtolower($matches[1]); + $action = $matches[2]; + $target = $matches[3] === '*' ? '*' : $matches[4]; + $preposition = $matches[5]; + $username = $matches[6]; + $budget = isset($matches[7]) ? trim($matches[7], "'\"") : null; + + if ($command === 'grant' && strtoupper($preposition) !== 'TO') { + throw GenericError::create('Invalid preposition for GRANT: Must use TO.', true); + } + if ($command === 'revoke' && strtoupper($preposition) !== 'FROM') { + throw GenericError::create('Invalid preposition for REVOKE: Must use FROM.', true); + } + if ($command === 'revoke' && $budget !== null) { + throw GenericError::create('REVOKE does not support WITH BUDGET.', true); + } + + $allowedActions = ['read', 'write', 'schema', 'admin', 'replication']; + if (!in_array(strtolower($action), $allowedActions)) { + throw GenericError::create('Invalid action: Must be one of read, write, schema, admin, replication.', true); + } + + if ($budget !== null && json_decode($budget, true) === null) { + throw GenericError::create('Invalid budget JSON.', true); + } + + $self->action = $action; + $self->target = $target; + $self->username = $username; + $self->budget = $budget; + } + + /** + * @throws GenericError + */ + private static function parsePasswordCommand(string $payload, self $self): void { + $pattern = '/^SET\s+PASSWORD\s+\'([^\']+)\'(?:\s+FOR\s+\'([^\']+)\')?$/i'; + + if (!preg_match($pattern, $payload, $matches)) { + throw GenericError::create('Invalid payload: Does not match SET PASSWORD command.', true); + } + + $self->username = $matches[2] ?? null; + $self->password = $matches[1]; + } + + /** + * Get the handler class name + * + * @return string + */ + public function getHandlerClassName(): string { + return __NAMESPACE__ . '\\' . $this->handler; + } +} diff --git a/src/Plugin/Auth/README.md b/src/Plugin/Auth/README.md new file mode 100644 index 00000000..f63d5d85 --- /dev/null +++ b/src/Plugin/Auth/README.md @@ -0,0 +1,12 @@ +# Auth Plugin + +The Auth plugin handles authentication and authorization commands for Manticoresearch Buddy, including user management, permissions, and password changes. It parses SQL-like queries that Manticore doesn't natively support and routes them to appropriate handlers. + +## Supported Queries + +- `CREATE USER 'username' IDENTIFIED BY 'password'` +- `DROP USER 'username'` +- `GRANT ON TO 'username' [WITH BUDGET ]` (actions: read, write, schema, admin, replication; target: '*' or table name) +- `REVOKE ON FROM 'username'` +- `SHOW MY PERMISSIONS` +- `SET PASSWORD 'newpass' [FOR 'username']` \ No newline at end of file diff --git a/src/Plugin/Auth/ShowHandler.php b/src/Plugin/Auth/ShowHandler.php new file mode 100644 index 00000000..c85e5d41 --- /dev/null +++ b/src/Plugin/Auth/ShowHandler.php @@ -0,0 +1,104 @@ +sendRequest('SHOW PERMISSIONS'); + if ($request->hasError()) { + throw GenericError::create((string)$request->getError()); + } + + $allPermissions = static::extractPermissionsFromResponse($request->getResult()->toArray()); + $myPermissions = static::filterPermissionsByUser($allPermissions, $payload->actingUser); + + return TaskResult::withData($myPermissions) + ->column('Username', Column::String) + ->column('action', Column::String) + ->column('Target', Column::String) + ->column('Allow', Column::String) + ->column('Budget', Column::String); + }; + + return Task::create( + $taskFn, [$this->payload, $this->manticoreClient] + )->run(); + } + + /** + * Extract permissions data from API response + * + * @param mixed $document + * @return array> + * @throws GenericError + */ + private static function extractPermissionsFromResponse(mixed $document): array { + if (!is_array($document) || !isset($document[0]['data'])) { + throw GenericError::create('Searchd failed with an empty response.'); + } + + $allPermissions = $document[0]['data']; + if (!is_array($allPermissions)) { + throw GenericError::create('Invalid permissions data format.'); + } + + return $allPermissions; + } + + /** + * Filter permissions by username + * + * @param array> $allPermissions + * @param string $actingUser + * @return array> + */ + private static function filterPermissionsByUser(array $allPermissions, string $actingUser): array { + $myPermissions = []; + + foreach ($allPermissions as $row) { + if (!is_array($row) || !isset($row['Username'])) { + continue; + } + + if ($row['Username'] !== $actingUser) { + continue; + } + + $myPermissions[] = $row; + } + + return $myPermissions; + } +} diff --git a/src/Plugin/Auth/UserHandler.php b/src/Plugin/Auth/UserHandler.php new file mode 100644 index 00000000..7e85546d --- /dev/null +++ b/src/Plugin/Auth/UserHandler.php @@ -0,0 +1,210 @@ + $this->processRequest(), + [$this->payload, $this->manticoreClient] + )->run(); + } + + /** + * Process the request based on operation type + * + * @return TaskResult + * @throws GenericError + */ + private function processRequest(): TaskResult { + if ($this->payload->username === null) { + throw GenericError::create('Username is required.'); + } + $username = addslashes($this->payload->username); + $count = $this->getUserCount($username); + + if ($this->payload->type === 'create') { + return $this->handleCreateUser($username, $count); + } + + if ($this->payload->type === 'drop') { + return $this->handleDropUser($username, $count); + } + + throw GenericError::create('Invalid operation type for UserHandler.'); + } + + /** + * Get the count of users with the given username + * + * @param string $username The username to check + * @return int + * @throws GenericError + */ + private function getUserCount(string $username): int { + $tableUsers = Payload::AUTH_USERS_TABLE; + $query = "SELECT count(*) as c FROM {$tableUsers} WHERE username = '{$username}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + $result = $resp->getResult()->toArray(); + if (!is_array($result) || !isset($result[0]['data'][0]['c'])) { + throw GenericError::create('Unexpected response format when checking user existence.'); + } + + return (int)$result[0]['data'][0]['c']; + } + + /** + * Validate username format and constraints + * + * @param string $username The username to validate + * @throws GenericError + */ + private function validateUsername(string $username): void { + if (empty($username)) { + throw GenericError::create('Username cannot be empty.'); + } + if (strlen($username) > 64) { + throw GenericError::create('Username is too long (max 64 characters).'); + } + if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $username)) { + throw GenericError::create('Username contains invalid characters.'); + } + } + + /** + * Generate a cryptographically secure random token + * + * @return string + * @throws GenericError + */ + private function generateToken(): string { + try { + return bin2hex(random_bytes(self::TOKEN_BYTES)); + } catch (Exception $e) { + throw GenericError::create('Failed to generate secure token: ' . $e->getMessage()); + } + } + + /** + * Handle user creation + * + * @param string $username The username to create + * @param int $count Existing user count + * @return TaskResult + * @throws GenericError + */ + private function handleCreateUser(string $username, int $count): TaskResult { + $this->validateUsername($username); + + if ($count > 0) { + throw GenericError::create("User '{$username}' already exists."); + } + + if (!isset($this->payload->password)) { + throw GenericError::create('Password is required for CREATE USER.'); + } + + $salt = bin2hex(random_bytes(20)); + $token = $this->generateToken(); + + // Generate hashes including the token hash for bearer_sha256 + $hashesJson = $this->generateHashesWithToken($this->payload->password, $token, $salt); + + $tableUsers = Payload::AUTH_USERS_TABLE; + $query = "INSERT INTO {$tableUsers} (username, salt, hashes) ". + "VALUES ('{$username}', '{$salt}', '{$hashesJson}')"; + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + // Return the generated token to the user + return TaskResult::withRow( + [ + 'token' => $token, + 'username' => $username, + 'generated_at' => date('Y-m-d H:i:s'), + ] + )->column('token', Column::String) + ->column('username', Column::String) + ->column('generated_at', Column::String); + } + + /** + * Handle user deletion + * + * @param string $username The username to delete + * @param int $count Existing user count + * @return TaskResult + * @throws GenericError + */ + private function handleDropUser(string $username, int $count): TaskResult { + if ($count === 0) { + throw GenericError::create("User '{$username}' does not exist."); + } + + $tablePerms = Payload::AUTH_PERMISSIONS_TABLE; + $query = "DELETE FROM {$tablePerms} WHERE username = '{$username}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + $tableUsers = Payload::AUTH_USERS_TABLE; + $query = "DELETE FROM {$tableUsers} WHERE username = '{$username}'"; + /** @var Response $resp */ + $resp = $this->manticoreClient->sendRequest($query); + + if ($resp->hasError()) { + throw GenericError::create((string)$resp->getError()); + } + + return TaskResult::none(); + } +} diff --git a/src/init.php b/src/init.php index 453fe889..784235f0 100644 --- a/src/init.php +++ b/src/init.php @@ -80,6 +80,7 @@ 'manticoresoftware/buddy-plugin-alter-column', 'manticoresoftware/buddy-plugin-alter-distributed-table', 'manticoresoftware/buddy-plugin-alter-rename-table', + 'manticoresoftware/buddy-plugin-auth', 'manticoresoftware/buddy-plugin-modify-table', 'manticoresoftware/buddy-plugin-knn', 'manticoresoftware/buddy-plugin-replace', diff --git a/test/Plugin/Auth/GrantRevokeHandlerTest.php b/test/Plugin/Auth/GrantRevokeHandlerTest.php new file mode 100644 index 00000000..0089cc6d --- /dev/null +++ b/test/Plugin/Auth/GrantRevokeHandlerTest.php @@ -0,0 +1,320 @@ + $responses + */ + private function createMockClient(array $responses): HTTPClient { + $clientMock = $this->createMock(HTTPClient::class); + $responseIndex = 0; + + $clientMock->method('sendRequest') + ->willReturnCallback( + function () use ($responses, &$responseIndex) { + if ($responseIndex < sizeof($responses)) { + return $responses[$responseIndex++]; + } + return Response::fromBody((string)json_encode([])); + } + ); + + return $clientMock; + } + + private function setClientOnHandler(GrantRevokeHandler $handler, HTTPClient $client): void { + $reflection = new \ReflectionClass($handler); + $property = $reflection->getProperty('manticoreClient'); + $property->setAccessible(true); + $property->setValue($handler, $client); + } + + public function testGrantSuccess(): void { + $payload = new Payload(); + $payload->type = 'grant'; + $payload->username = 'testuser'; + $payload->action = 'read'; + $payload->target = '*'; + $payload->budget = '{}'; + + $handler = new GrantRevokeHandler($payload); + + $responses = [ + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 1]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // User exists + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 0]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // Permission doesn't exist + Response::fromBody((string)json_encode([])), // INSERT success + ]; + + $client = $this->createMockClient($responses); + $this->setClientOnHandler($handler, $client); + + $result = $this->invokeMethod($handler, 'handleGrant', ['testuser', 'read', '*', '{}']); + $this->assertInstanceOf(TaskResult::class, $result); + $struct = $result->getStruct(); + $this->assertIsArray($struct); + $this->assertEmpty($struct[0]['data'] ?? []); + } + + public function testGrantNonExistentUser(): void { + $payload = new Payload(); + $payload->type = 'grant'; + $payload->username = 'nonexistent'; + $payload->action = 'read'; + $payload->target = '*'; + $payload->budget = '{}'; + + $handler = new GrantRevokeHandler($payload); + + $responses = [ + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 0]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // User doesn't exist + ]; + + $client = $this->createMockClient($responses); + $this->setClientOnHandler($handler, $client); + + try { + $this->invokeMethod($handler, 'handleGrant', ['nonexistent', 'read', '*', '{}']); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals("User 'nonexistent' does not exist.", $e->getResponseError()); + } + } + + public function testGrantDuplicatePermission(): void { + $payload = new Payload(); + $payload->type = 'grant'; + $payload->username = 'testuser'; + $payload->action = 'read'; + $payload->target = '*'; + $payload->budget = '{}'; + + $handler = new GrantRevokeHandler($payload); + + $responses = [ + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 1]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // User exists + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 1]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // Permission already exists + ]; + + $client = $this->createMockClient($responses); + $this->setClientOnHandler($handler, $client); + + try { + $this->invokeMethod($handler, 'handleGrant', ['testuser', 'read', '*', '{}']); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals("User 'testuser' already has 'read' permission on '*'.", $e->getResponseError()); + } + } + + public function testRevokeSuccess(): void { + $payload = new Payload(); + $payload->type = 'revoke'; + $payload->username = 'testuser'; + $payload->action = 'read'; + $payload->target = '*'; + + $handler = new GrantRevokeHandler($payload); + + $responses = [ + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 1]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // User exists + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 1]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // Permission exists + Response::fromBody((string)json_encode([])), // DELETE success + ]; + + $client = $this->createMockClient($responses); + $this->setClientOnHandler($handler, $client); + + $result = $this->invokeMethod($handler, 'handleRevoke', ['testuser', 'read', '*']); + $this->assertInstanceOf(TaskResult::class, $result); + $struct = $result->getStruct(); + $this->assertIsArray($struct); + $this->assertEmpty($struct[0]['data'] ?? []); + } + + public function testRevokeNonExistentUser(): void { + $payload = new Payload(); + $payload->type = 'revoke'; + $payload->username = 'nonexistent'; + $payload->action = 'read'; + $payload->target = '*'; + + $handler = new GrantRevokeHandler($payload); + + $responses = [ + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 0]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // User doesn't exist + ]; + + $client = $this->createMockClient($responses); + $this->setClientOnHandler($handler, $client); + + try { + $this->invokeMethod($handler, 'handleRevoke', ['nonexistent', 'read', '*']); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals("User 'nonexistent' does not exist.", $e->getResponseError()); + } + } + + public function testRevokeNonExistentPermission(): void { + $payload = new Payload(); + $payload->type = 'revoke'; + $payload->username = 'testuser'; + $payload->action = 'write'; + $payload->target = '*'; + + $handler = new GrantRevokeHandler($payload); + + $responses = [ + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 1]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // User exists + Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => 0]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ), // Permission doesn't exist + ]; + + $client = $this->createMockClient($responses); + $this->setClientOnHandler($handler, $client); + + try { + $this->invokeMethod($handler, 'handleRevoke', ['testuser', 'write', '*']); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals("User 'testuser' does not have 'write' permission on '*'.", $e->getResponseError()); + } + } + + public function testInvalidOperationType(): void { + $payload = new Payload(); + $payload->type = 'invalid'; + $payload->username = 'testuser'; + $payload->action = 'read'; + $payload->target = '*'; + + $handler = new GrantRevokeHandler($payload); + + try { + $this->invokeMethod($handler, 'processRequest', []); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals('Invalid operation type for GrantRevokeHandler.', $e->getResponseError()); + } + } +} diff --git a/test/Plugin/Auth/HashGeneratorTraitTest.php b/test/Plugin/Auth/HashGeneratorTraitTest.php new file mode 100644 index 00000000..3f4e9dde --- /dev/null +++ b/test/Plugin/Auth/HashGeneratorTraitTest.php @@ -0,0 +1,288 @@ +generateHashesWithToken($password, $token, $salt); + } + + /** + * @param array $existingHashes + */ + public function callUpdatePasswordHashes(string $newPassword, string $salt, array $existingHashes): string { + return $this->updatePasswordHashes($newPassword, $salt, $existingHashes); + } + + /** + * @param array $hashes + */ + public function callValidateHashesStructure(array $hashes): void { + $this->validateHashesStructure($hashes); + } + + public function callGenerateTokenHash(string $token, string $salt): string { + return $this->generateTokenHash($token, $salt); + } + }; + } + + public function testGenerateHashesWithToken(): void { + $instance = $this->getTraitInstance(); + $password = 'testpassword'; + $token = 'abcdef123456'; + $salt = 'testsalt'; + + /** @phpstan-ignore-next-line */ + $result = $instance->callGenerateHashesWithToken($password, $token, $salt); + $this->assertIsString($result); + + // Should return escaped JSON string + $unescapedJson = stripslashes($result); + $hashes = json_decode($unescapedJson, true); + + $this->assertIsArray($hashes); + $this->assertArrayHasKey('password_sha1_no_salt', $hashes); + $this->assertArrayHasKey('password_sha256', $hashes); + $this->assertArrayHasKey('bearer_sha256', $hashes); + + // Verify hash values + $this->assertEquals(sha1($password), $hashes['password_sha1_no_salt']); + $this->assertEquals(hash('sha256', $salt . $password), $hashes['password_sha256']); + $this->assertEquals(hash('sha256', $salt . hash('sha256', $token)), $hashes['bearer_sha256']); + } + + public function testUpdatePasswordHashes(): void { + $instance = $this->getTraitInstance(); + $newPassword = 'newpassword'; + $salt = 'testsalt'; + $existingBearerHash = 'existing_bearer_hash'; + + $existingHashes = [ + 'password_sha1_no_salt' => 'old_sha1', + 'password_sha256' => 'old_sha256', + 'bearer_sha256' => $existingBearerHash, + ]; + + /** @phpstan-ignore-next-line */ + $result = $instance->callUpdatePasswordHashes($newPassword, $salt, $existingHashes); + $this->assertIsString($result); + + // Should return escaped JSON string + $unescapedJson = stripslashes($result); + $updatedHashes = json_decode($unescapedJson, true); + + $this->assertIsArray($updatedHashes); + + // Password hashes should be updated + $this->assertEquals(sha1($newPassword), $updatedHashes['password_sha1_no_salt']); + $this->assertEquals(hash('sha256', $salt . $newPassword), $updatedHashes['password_sha256']); + + // Bearer hash should be preserved + $this->assertEquals($existingBearerHash, $updatedHashes['bearer_sha256']); + } + + public function testUpdatePasswordHashesMissingBearerHash(): void { + $instance = $this->getTraitInstance(); + $existingHashes = [ + 'password_sha1_no_salt' => 'old_sha1', + 'password_sha256' => 'old_sha256', + // Missing bearer_sha256 + ]; + + try { + /** @phpstan-ignore-next-line */ + $instance->callUpdatePasswordHashes('newpass', 'salt', $existingHashes); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals('Existing bearer_sha256 hash is required for password update.', $e->getResponseError()); + } + } + + public function testValidateHashesStructure(): void { + $instance = $this->getTraitInstance(); + + // Valid structure + $validHashes = [ + 'password_sha1_no_salt' => 'sha1_hash', + 'password_sha256' => 'sha256_hash', + 'bearer_sha256' => 'bearer_hash', + ]; + + // Should not throw exception + /** @phpstan-ignore-next-line */ + $instance->callValidateHashesStructure($validHashes); + $this->assertTrue(true); // If we get here, validation passed + } + + public function testValidateHashesStructureMissingKeys(): void { + $instance = $this->getTraitInstance(); + + $invalidStructures = [ + // Missing password_sha1_no_salt + [ + 'password_sha256' => 'sha256_hash', + 'bearer_sha256' => 'bearer_hash', + ], + // Missing password_sha256 + [ + 'password_sha1_no_salt' => 'sha1_hash', + 'bearer_sha256' => 'bearer_hash', + ], + // Missing bearer_sha256 + [ + 'password_sha1_no_salt' => 'sha1_hash', + 'password_sha256' => 'sha256_hash', + ], + ]; + + foreach ($invalidStructures as $index => $invalidHashes) { + try { + /** @phpstan-ignore-next-line */ + $instance->callValidateHashesStructure($invalidHashes); + $this->fail("Expected exception for invalid structure #$index"); + } catch (GenericError $e) { + $this->assertStringContainsString('Invalid hash structure: missing or invalid', $e->getResponseError()); + } + } + } + + public function testValidateHashesStructureInvalidTypes(): void { + $instance = $this->getTraitInstance(); + + $invalidTypes = [ + [ + 'password_sha1_no_salt' => 123, // Should be string + 'password_sha256' => 'sha256_hash', + 'bearer_sha256' => 'bearer_hash', + ], + [ + 'password_sha1_no_salt' => 'sha1_hash', + 'password_sha256' => null, // Should be string + 'bearer_sha256' => 'bearer_hash', + ], + [ + 'password_sha1_no_salt' => 'sha1_hash', + 'password_sha256' => 'sha256_hash', + 'bearer_sha256' => [], // Should be string + ], + ]; + + foreach ($invalidTypes as $index => $invalidHashes) { + try { + /** @phpstan-ignore-next-line */ + $instance->callValidateHashesStructure($invalidHashes); + $this->fail("Expected exception for invalid type #$index"); + } catch (GenericError $e) { + $this->assertStringContainsString('Invalid hash structure: missing or invalid', $e->getResponseError()); + } + } + } + + public function testGenerateTokenHash(): void { + $instance = $this->getTraitInstance(); + $token = 'test_token_123'; + $salt = 'test_salt'; + + /** @phpstan-ignore-next-line */ + $result = $instance->callGenerateTokenHash($token, $salt); + $this->assertIsString($result); + + // Should be a SHA256 hash (64 characters) + $this->assertEquals(64, strlen($result)); + $this->assertTrue(ctype_xdigit($result)); // Should be hex string + + // Should be deterministic + /** @phpstan-ignore-next-line */ + $result2 = $instance->callGenerateTokenHash($token, $salt); + $this->assertEquals($result, $result2); + + // Different inputs should produce different results + /** @phpstan-ignore-next-line */ + $result3 = $instance->callGenerateTokenHash('different_token', $salt); + $this->assertNotEquals($result, $result3); + + /** @phpstan-ignore-next-line */ + $result4 = $instance->callGenerateTokenHash($token, 'different_salt'); + $this->assertNotEquals($result, $result4); + + // Verify the actual computation + $expectedHash = hash('sha256', $salt . hash('sha256', $token)); + $this->assertEquals($expectedHash, $result); + } + + public function testTokenHashConsistency(): void { + $instance = $this->getTraitInstance(); + $password = 'password123'; + $token = 'token456'; + $salt = 'salt789'; + + // Generate hashes with token + /** @phpstan-ignore-next-line */ + $hashesJson = $instance->callGenerateHashesWithToken($password, $token, $salt); + $hashes = json_decode(stripslashes($hashesJson), true); + + $this->assertIsArray($hashes); + + // Generate token hash separately + /** @phpstan-ignore-next-line */ + $separateTokenHash = $instance->callGenerateTokenHash($token, $salt); + + // They should match + $this->assertEquals($separateTokenHash, $hashes['bearer_sha256']); + } + + public function testHashGenerationWithSpecialCharacters(): void { + $instance = $this->getTraitInstance(); + + $specialCases = [ + ['password' => 'p@$$w0rd!', 'token' => 't0k3n#123', 'salt' => 's@lt&chars'], + ['password' => 'пароль', 'token' => 'токен', 'salt' => 'соль'], + ['password' => '', 'token' => '', 'salt' => ''], // Empty strings + ['password' => 'a', 'token' => 'b', 'salt' => 'c'], // Single characters + ]; + + foreach ($specialCases as $case) { + /** @phpstan-ignore-next-line */ + $result = $instance->callGenerateHashesWithToken($case['password'], $case['token'], $case['salt']); + $this->assertIsString($result); + + $hashes = json_decode(stripslashes($result), true); + $this->assertIsArray($hashes); + $this->assertArrayHasKey('password_sha1_no_salt', $hashes); + $this->assertArrayHasKey('password_sha256', $hashes); + $this->assertArrayHasKey('bearer_sha256', $hashes); + } + } +} diff --git a/test/Plugin/Auth/IntegrationTest.php b/test/Plugin/Auth/IntegrationTest.php new file mode 100644 index 00000000..5d246da8 --- /dev/null +++ b/test/Plugin/Auth/IntegrationTest.php @@ -0,0 +1,450 @@ + GRANT permissions -> SHOW permissions -> DROP USER + + // Step 1: Create user + $createRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'payload' => "CREATE USER 'testuser' IDENTIFIED BY 'password123'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $createPayload = Payload::fromRequest($createRequest); + + // Mock responses for CREATE USER + $createResponses = [ + AuthTestHelpers::createUserExistsResponse(false), // User doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT success + ]; + + $createHandler = new UserHandler($createPayload); + $createClient = $this->createSequentialClientMock($createResponses); + $this->injectClientMock($createHandler, $createClient); + + $createTask = $createHandler->run(); + $this->assertTrue($createTask->isSucceed()); + $createResult = $createTask->getResult(); + $struct = $createResult->getStruct(); + $this->assertIsArray($struct); + $this->assertNotEmpty($struct); + $data = $struct[0]['data'][0]; + $this->assertIsArray($data); + $this->assertArrayHasKey('token', $data); + $this->assertArrayHasKey('username', $data); + $this->assertEquals('testuser', $data['username']); + + // Step 2: Grant permissions + $grantRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P02: syntax error, unexpected identifier near 'GRANT read ON * TO 'testuser''", + 'payload' => "GRANT read ON * TO 'testuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $grantPayload = Payload::fromRequest($grantRequest); + + // Mock responses for GRANT + $grantResponses = [ + AuthTestHelpers::createUserExistsResponse(true), // User exists + AuthTestHelpers::createPermissionExistsResponse(false), // Permission doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT permission success + ]; + + $grantHandler = new GrantRevokeHandler($grantPayload); + $grantClient = $this->createSequentialClientMock($grantResponses); + $this->injectClientMock($grantHandler, $grantClient); + + $grantTask = $grantHandler->run(); + $this->assertTrue($grantTask->isSucceed()); + + // Step 3: Show permissions + $showRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P01: syntax error, unexpected identifier, expecting VARIABLES near 'MY PERMISSIONS'", + 'payload' => 'SHOW MY PERMISSIONS', + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'testuser', + ] + ); + + $showPayload = Payload::fromRequest($showRequest); + + $showPermissions = [ + ['Username' => 'testuser', 'action' => 'read', 'Target' => '*', 'Allow' => '1', 'Budget' => '{}'], + ]; + + $showHandler = new ShowHandler($showPayload); + $showClient = $this->createSequentialClientMock( + [ + AuthTestHelpers::createPermissionResponse($showPermissions), + ] + ); + $this->injectClientMock($showHandler, $showClient); + + $showTask = $showHandler->run(); + $this->assertTrue($showTask->isSucceed()); + $showResult = $showTask->getResult(); + $showStruct = $showResult->getStruct(); + $this->assertIsArray($showStruct); + $this->assertCount(1, $showStruct[0]['data']); + $this->assertEquals('testuser', $showStruct[0]['data'][0]['Username']); + + // Step 4: Drop user + $dropRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P03: syntax error, unexpected tablename, expecting FUNCTION or PLUGIN or TABLE near 'user", + 'payload' => "DROP USER 'testuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $dropPayload = Payload::fromRequest($dropRequest); + + $dropResponses = [ + AuthTestHelpers::createUserExistsResponse(true), // User exists + AuthTestHelpers::createEmptySuccessResponse(), // DELETE permissions + AuthTestHelpers::createEmptySuccessResponse(), // DELETE user + ]; + + $dropHandler = new UserHandler($dropPayload); + $dropClient = $this->createSequentialClientMock($dropResponses); + $this->injectClientMock($dropHandler, $dropClient); + + $dropTask = $dropHandler->run(); + $this->assertTrue($dropTask->isSucceed()); + } + + public function testPasswordUpdateWorkflow(): void { + // Test: CREATE USER -> SET PASSWORD -> verify password hashes + + // Step 1: Create user with initial password + $createRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'payload' => "CREATE USER 'passuser' IDENTIFIED BY 'oldpass123'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $createPayload = Payload::fromRequest($createRequest); + $createHandler = new UserHandler($createPayload); + + $createClient = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserExistsResponse(false), // User doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT success + ] + ); + $this->injectClientMock($createHandler, $createClient); + + $createTask = $createHandler->run(); + $this->assertTrue($createTask->isSucceed()); + + // Step 2: Change password + $passwordRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P01: syntax error, unexpected string, expecting '=' near", + 'payload' => "SET PASSWORD 'newpass456' FOR 'passuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $passwordPayload = Payload::fromRequest($passwordRequest); + $passwordHandler = new PasswordHandler($passwordPayload); + + // Mock existing user data + $existingHashes = (string)json_encode( + [ + 'password_sha1_no_salt' => 'old_sha1_hash', + 'password_sha256' => 'old_sha256_hash', + 'bearer_sha256' => 'existing_token_hash', + ] + ); + + $passwordClient = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserDataResponse('salt123', $existingHashes), // Get user data + AuthTestHelpers::createEmptySuccessResponse(), // REPLACE success + ] + ); + $this->injectClientMock($passwordHandler, $passwordClient); + + $passwordTask = $passwordHandler->run(); + $this->assertTrue($passwordTask->isSucceed()); + } + + public function testMultiplePermissionGrants(): void { + // Test granting multiple permissions to same user + + $permissions = [ + ['action' => 'read', 'target' => '*'], + ['action' => 'write', 'target' => "'table/test'"], + ['action' => 'schema', 'target' => '*'], + ]; + + foreach ($permissions as $index => $permission) { + $grantRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P02: syntax error, unexpected identifier near '. + "'GRANT {$permission['action']} ON {$permission['target']} TO 'multiuser''", + 'payload' => "GRANT {$permission['action']} ON {$permission['target']} TO 'multiuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $grantPayload = Payload::fromRequest($grantRequest); + $grantHandler = new GrantRevokeHandler($grantPayload); + + $grantClient = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserExistsResponse(true), // User exists + AuthTestHelpers::createPermissionExistsResponse(false), // Permission doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT success + ] + ); + $this->injectClientMock($grantHandler, $grantClient); + + $grantTask = $grantHandler->run(); + $this->assertTrue($grantTask->isSucceed(), "Failed to grant permission #$index"); + } + } + + public function testErrorPropagation(): void { + // Test that database errors are properly propagated + + $createRequest = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'payload' => "CREATE USER 'erroruser' IDENTIFIED BY 'pass123'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $createPayload = Payload::fromRequest($createRequest); + $createHandler = new UserHandler($createPayload); + + // Mock database error response + $errorResponse = $this->createMock(Response::class); + $errorResponse->method('hasError')->willReturn(true); + $errorResponse->method('getError')->willReturn('Database connection failed'); + + $createClient = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserExistsResponse(false), // User doesn't exist + $this->createErrorResponse('Database connection failed'), // INSERT fails + ] + ); + $this->injectClientMock($createHandler, $createClient); + + $task = $createHandler->run(); + $this->assertFalse($task->isSucceed(), 'Task should fail with database error'); + $this->assertStringContainsString('Database connection failed', $task->getError()->getResponseError()); + } + + public function testPayloadRouting(): void { + // Test that different commands are routed to correct handlers + + $testCases = [ + [ + 'payload' => "CREATE USER 'test' IDENTIFIED BY 'pass'", + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'expectedHandler' => 'UserHandler', + ], + [ + 'payload' => "DROP USER 'test'", + 'error' => "P03: syntax error, unexpected tablename, expecting FUNCTION or PLUGIN or TABLE near 'user", + 'expectedHandler' => 'UserHandler', + ], + [ + 'payload' => "GRANT read ON * TO 'test'", + 'error' => "P02: syntax error, unexpected identifier near 'GRANT", + 'expectedHandler' => 'GrantRevokeHandler', + ], + [ + 'payload' => "REVOKE read ON * FROM 'test'", + 'error' => "P02: syntax error, unexpected identifier near 'REVOKE", + 'expectedHandler' => 'GrantRevokeHandler', + ], + [ + 'payload' => "SET PASSWORD 'newpass'", + 'error' => "P01: syntax error, unexpected string, expecting '=' near", + 'expectedHandler' => 'PasswordHandler', + ], + [ + 'payload' => 'SHOW MY PERMISSIONS', + 'error' => "P01: syntax error, unexpected identifier, expecting VARIABLES near 'MY PERMISSIONS'", + 'expectedHandler' => 'ShowHandler', + ], + ]; + + foreach ($testCases as $case) { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => $case['error'], + 'payload' => $case['payload'], + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $payload = Payload::fromRequest($request); + $handlerClassName = $payload->getHandlerClassName(); + + $this->assertStringEndsWith( + $case['expectedHandler'], $handlerClassName, + "Wrong handler for: {$case['payload']}" + ); + } + } + + public function testConcurrentOperationsSimulation(): void { + // Simulate multiple operations happening "simultaneously" + // (In real scenario these would be actual concurrent requests) + + $operations = [ + ['type' => 'create', 'user' => 'user1'], + ['type' => 'create', 'user' => 'user2'], + ['type' => 'grant', 'user' => 'user1', 'action' => 'read'], + ['type' => 'grant', 'user' => 'user2', 'action' => 'write'], + ]; + + $results = []; + + foreach ($operations as $op) { + switch ($op['type']) { + case 'create': + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'payload' => "CREATE USER '{$op['user']}' IDENTIFIED BY 'pass123'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $payload = Payload::fromRequest($request); + $handler = new UserHandler($payload); + + $client = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserExistsResponse(false), // User doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT success + ] + ); + break; + + case 'grant': + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P02: syntax error, unexpected identifier near '. + "'GRANT {$op['action']} ON * TO '{$op['user']}''", + 'payload' => "GRANT {$op['action']} ON * TO '{$op['user']}'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + $payload = Payload::fromRequest($request); + $handler = new GrantRevokeHandler($payload); + + $client = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserExistsResponse(true), // User exists + AuthTestHelpers::createPermissionExistsResponse(false), // Permission doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT success + ] + ); + break; + } + + $this->injectClientMock($handler, $client); + $task = $handler->run(); + $results[] = $task->isSucceed(); + } + + // All operations should succeed + foreach ($results as $index => $success) { + $this->assertTrue($success, "Operation #$index failed"); + } + } +} diff --git a/test/Plugin/Auth/PasswordHandlerTest.php b/test/Plugin/Auth/PasswordHandlerTest.php new file mode 100644 index 00000000..3af396c7 --- /dev/null +++ b/test/Plugin/Auth/PasswordHandlerTest.php @@ -0,0 +1,119 @@ +password = 'newpass123'; + $payload->actingUser = 'testuser'; + + $handler = new PasswordHandler($payload); + + // Mock existing user data with a valid hashes structure + $existingHashes = json_encode( + [ + 'password_sha1_no_salt' => 'old_sha1', + 'password_sha256' => 'old_sha256', + 'bearer_sha256' => 'existing_token_hash', + ] + ); + + $userDataResponse = $this->createStructResponse([['salt' => 'salt123', 'hashes' => $existingHashes]]); + $replaceResponse = $this->createEmptySuccessResponse(); + + $clientMock = $this->createSequentialClientMock( + [ + $userDataResponse, // getUserData + $replaceResponse, // REPLACE + ] + ); + + $this->injectClientMock($handler, $clientMock); + + $result = self::invokeMethod($handler, 'processRequest'); + $this->assertInstanceOf(TaskResult::class, $result); + $struct = $result->getStruct(); + $this->assertIsArray($struct); + $this->assertEmpty($struct[0]['data'] ?? []); + } + + public function testHandlePasswordUpdateNoPassword(): void { + $payload = new Payload(); + $payload->actingUser = 'testuser'; + $payload->password = ''; // Empty password + + $handler = new PasswordHandler($payload); + + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'processRequest'), + 'Password cannot be empty.' + ); + } + + public function testHandlePasswordUpdateNonExistentUser(): void { + $payload = new Payload(); + $payload->password = 'newpass123'; + $payload->actingUser = 'nonexistent'; + + $handler = new PasswordHandler($payload); + $clientMock = $this->createSequentialClientMock( + [ + $this->createStructResponse([]), // Empty user data response + ] + ); + + $this->injectClientMock($handler, $clientMock); + + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'processRequest'), + "User 'nonexistent' does not exist." + ); + } + + public function testValidatePassword(): void { + $payload = new Payload(); + $handler = new PasswordHandler($payload); + + // Test valid passwords + $validPasswords = ['password123', 'P@ssw0rd!', 'averylongpasswordthatisvalid', '12345678']; + foreach ($validPasswords as $password) { + // Should not throw exception + self::invokeMethod($handler, 'validatePassword', [$password]); + $this->assertTrue(true); // If we get here, validation passed + } + + // Test invalid passwords + $invalidPasswords = [ + ['', 'Password cannot be empty.'], + ['short', 'Password must be at least 8 characters long.'], + [str_repeat('a', 129), 'Password is too long (max 128 characters).'], + ]; + + foreach ($invalidPasswords as [$password, $expectedMessage]) { + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'validatePassword', [$password]), + $expectedMessage + ); + } + } +} diff --git a/test/Plugin/Auth/PayloadTest.php b/test/Plugin/Auth/PayloadTest.php new file mode 100644 index 00000000..27dba9d0 --- /dev/null +++ b/test/Plugin/Auth/PayloadTest.php @@ -0,0 +1,280 @@ + "CREATE USER 'testuser' IDENTIFIED BY 'testpass'", + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'expected' => true, + ], + [ + 'query' => "DROP USER 'testuser'", + 'error' => 'P03: syntax error, unexpected tablename, expecting '. + "FUNCTION or PLUGIN or TABLE near 'user", + 'expected' => true, + ], + [ + 'query' => "GRANT read ON * TO 'testuser'", + 'error' => "P02: syntax error, unexpected identifier near 'GRANT", + 'expected' => true, + ], + [ + 'query' => "REVOKE read ON * FROM 'testuser'", + 'error' => "P02: syntax error, unexpected identifier near 'REVOKE", + 'expected' => true, + ], + [ + 'query' => 'SHOW MY PERMISSIONS', + 'error' => 'P01: syntax error, unexpected identifier, '. + "expecting VARIABLES near 'MY PERMISSIONS'", + 'expected' => true, + ], + [ + 'query' => "SET PASSWORD 'abcdef'", + 'error' => "P01: syntax error, unexpected string, expecting '=' near", + 'expected' => true, + ], + [ + 'query' => 'INVALID QUERY', + 'error' => 'P01: syntax error, unexpected identifier', + 'expected' => false, + ], + ]; + + foreach ($testCases as $case) { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => $case['error'], + 'payload' => $case['query'], + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + ] + ); + $this->assertEquals( + $case['expected'], + Payload::hasMatch($request), + "Failed for query: {$case['query']}" + ); + } + } + + /** + * Test fromRequest for GRANT command + */ + public function testFromRequestGrant(): void { + echo "\nTesting fromRequest for GRANT command\n"; + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P02: syntax error, unexpected '. + "identifier near 'GRANT read ON * TO 'testuser''", + 'payload' => "GRANT read ON * TO 'testuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'all', + ] + ); + $payload = Payload::fromRequest($request); + + $this->assertEquals('grant', $payload->type); + $this->assertEquals('read', $payload->action); + $this->assertEquals('*', $payload->target); + $this->assertEquals('testuser', $payload->username); + $this->assertNull($payload->budget); + $this->assertEquals('all', $payload->actingUser); + $this->assertEquals( + 'Manticoresearch\Buddy\Base\Plugin\Auth\GrantRevokeHandler', + $payload->getHandlerClassName() + ); + } + + /** + * Test fromRequest for REVOKE command + */ + public function testFromRequestRevoke(): void { + echo "\nTesting fromRequest for REVOKE command\n"; + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P02: syntax error, unexpected '. + "identifier near 'REVOKE read ON * FROM 'testuser''", + 'payload' => "REVOKE read ON * FROM 'testuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'all', + ] + ); + $payload = Payload::fromRequest($request); + + $this->assertEquals('revoke', $payload->type); + $this->assertEquals('read', $payload->action); + $this->assertEquals('*', $payload->target); + $this->assertEquals('testuser', $payload->username); + $this->assertNull($payload->budget); + $this->assertEquals('all', $payload->actingUser); + $this->assertEquals( + 'Manticoresearch\Buddy\Base\Plugin\Auth\GrantRevokeHandler', + $payload->getHandlerClassName() + ); + } + + /** + * Test parseGrantRevokeCommand with invalid action + */ + public function testParseGrantRevokeInvalidAction(): void { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P02: syntax error, unexpected identifier near 'GRANT invalid ON * TO 'testuser''", + 'payload' => "GRANT invalid ON * TO 'testuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + + try { + Payload::fromRequest($request); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals( + 'Invalid action: Must be one of read, write, schema, admin, replication.', + $e->getResponseError() + ); + } + } + + /** + * Test fromRequest with invalid query + */ + public function testFromRequestInvalidQuery(): void { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P01: syntax error, unexpected identifier', + 'payload' => 'INVALID QUERY', + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'all', + ] + ); + + try { + Payload::fromRequest($request); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals('Failed to handle your query', $e->getResponseError()); + } + } + + public function testFromRequestCreateUser(): void { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P03: syntax error, unexpected tablename, '. + "expecting CLUSTER or FUNCTION or PLUGIN or TABLE near 'USER", + 'payload' => "CREATE USER 'newuser' IDENTIFIED BY 'password123'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + $payload = Payload::fromRequest($request); + + $this->assertEquals('create', $payload->type); + $this->assertEquals('newuser', $payload->username); + $this->assertEquals('password123', $payload->password); + $this->assertEquals('admin', $payload->actingUser); + $this->assertEquals( + 'Manticoresearch\Buddy\Base\Plugin\Auth\UserHandler', + $payload->getHandlerClassName() + ); + } + + public function testFromRequestDropUser(): void { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => 'P03: syntax error, unexpected tablename, '. + "expecting FUNCTION or PLUGIN or TABLE near 'user", + 'payload' => "DROP USER 'olduser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + $payload = Payload::fromRequest($request); + + $this->assertEquals('drop', $payload->type); + $this->assertEquals('olduser', $payload->username); + $this->assertNull($payload->password); + $this->assertEquals('admin', $payload->actingUser); + $this->assertEquals( + 'Manticoresearch\Buddy\Base\Plugin\Auth\UserHandler', + $payload->getHandlerClassName() + ); + } + + public function testFromRequestSetPassword(): void { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P01: syntax error, unexpected string, expecting '=' near", + 'payload' => "SET PASSWORD 'newpass123' FOR 'testuser'", + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'admin', + ] + ); + $payload = Payload::fromRequest($request); + + $this->assertEquals('set_password', $payload->type); + $this->assertEquals('testuser', $payload->username); + $this->assertEquals('newpass123', $payload->password); + $this->assertEquals('admin', $payload->actingUser); + $this->assertEquals( + 'Manticoresearch\Buddy\Base\Plugin\Auth\PasswordHandler', + $payload->getHandlerClassName() + ); + } +} diff --git a/test/Plugin/Auth/ShowHandlerTest.php b/test/Plugin/Auth/ShowHandlerTest.php new file mode 100644 index 00000000..f8b60c12 --- /dev/null +++ b/test/Plugin/Auth/ShowHandlerTest.php @@ -0,0 +1,128 @@ +actingUser = 'testuser'; + + $handler = new ShowHandler($payload); + $permissionsData = [ + ['Username' => 'testuser', 'action' => 'read', 'Target' => '*', 'Allow' => '1', 'Budget' => '{}'], + ]; + + $permissionsResponse = AuthTestHelpers::createPermissionResponse($permissionsData); + $struct = $this->getStruct($permissionsResponse, $handler); + $this->assertIsArray($struct); + $this->assertEquals($permissionsData, $struct[0]['data']); + } + + public function testRunShowPermissionsEmpty(): void { + $payload = new Payload(); + $payload->actingUser = 'nonexistent'; + + $handler = new ShowHandler($payload); + $emptyResponse = AuthTestHelpers::createPermissionResponse([]); + $struct = $this->getStruct($emptyResponse, $handler); + $this->assertIsArray($struct); + $this->assertEmpty($struct[0]['data'] ?? []); + } + + public function testShowMyPermissionsWithFiltering(): void { + $request = Request::fromArray( + [ + 'version' => Buddy::PROTOCOL_VERSION, + 'error' => "P01: syntax error, unexpected identifier, expecting VARIABLES near 'MY PERMISSIONS'", + 'payload' => 'SHOW MY PERMISSIONS', + 'format' => RequestFormat::SQL, + 'endpointBundle' => ManticoreEndpoint::Sql, + 'path' => 'sql?mode=raw', + 'user' => 'user2', + ] + ); + $payload = Payload::fromRequest($request); + + $mockPermissions = [ + ['Username' => 'user1', 'action' => 'read', 'Target' => '*', 'Allow' => '1', 'Budget' => '{}'], + ['Username' => 'user2', 'action' => 'write', 'Target' => 'table/test', 'Allow' => '1', 'Budget' => '{}'], + ['Username' => 'user3', 'action' => 'admin', 'Target' => '*', 'Allow' => '1', 'Budget' => '{}'], + ]; + + $permissionsResponse = AuthTestHelpers::createPermissionResponse($mockPermissions); + $clientMock = $this->createSequentialClientMock([$permissionsResponse]); + + $handler = new ShowHandler($payload); + $this->injectClientMock($handler, $clientMock); + + $task = $handler->run(); + $this->assertTrue($task->isSucceed()); + + $struct = $task->getResult()->getStruct(); + $this->assertIsArray($struct); + $resultData = $struct[0]['data']; + $this->assertCount(1, $resultData); // Only user2 permissions + $this->assertEquals('user2', $resultData[0]['Username']); + $this->assertEquals('write', $resultData[0]['action']); + } + + public function testShowMyPermissionsError(): void { + $payload = new Payload(); + $payload->actingUser = 'testuser'; + + $errorResponse = $this->createErrorResponse('Database connection failed'); + $clientMock = $this->createSequentialClientMock([$errorResponse]); + + $handler = new ShowHandler($payload); + $this->injectClientMock($handler, $clientMock); + + $task = $handler->run(); + $this->assertFalse($task->isSucceed()); + $this->assertStringContainsString( + 'Database connection failed', + $task->getError()->getResponseError() + ); + } + + /** + * @param Response $permissionsResponse + * @param ShowHandler $handler + * + * @return mixed + */ + public function getStruct( + Response $permissionsResponse, + ShowHandler $handler + ): mixed { + $clientMock = $this->createSequentialClientMock([$permissionsResponse]); + $this->injectClientMock($handler, $clientMock); + + $task = $handler->run(); + $result = $task->getResult(); + $this->assertInstanceOf(TaskResult::class, $result); + return $result->getStruct(); + } +} diff --git a/test/Plugin/Auth/UserHandlerTest.php b/test/Plugin/Auth/UserHandlerTest.php new file mode 100644 index 00000000..823b1f4a --- /dev/null +++ b/test/Plugin/Auth/UserHandlerTest.php @@ -0,0 +1,165 @@ +type = 'create'; + $payload->username = 'testuser'; + $payload->password = 'testpass123'; + + $handler = new UserHandler($payload); + $clientMock = $this->createSequentialClientMock( + [ + AuthTestHelpers::createUserExistsResponse(false), // User doesn't exist + AuthTestHelpers::createEmptySuccessResponse(), // INSERT success + ] + ); + + $this->injectClientMock($handler, $clientMock); + + $result = self::invokeMethod($handler, 'handleCreateUser', ['testuser', 0]); + $this->assertInstanceOf(TaskResult::class, $result); + + // Should return token data for user + $struct = $result->getStruct(); + $this->assertIsArray($struct); + $this->assertNotEmpty($struct); + $data = $struct[0]['data'][0]; + $this->assertIsArray($data); + $this->assertArrayHasKey('token', $data); + $this->assertArrayHasKey('username', $data); + $this->assertEquals('testuser', $data['username']); + } + + public function testHandleCreateUserAlreadyExists(): void { + $payload = new Payload(); + $payload->type = 'create'; + $payload->username = 'testuser'; + $payload->password = 'testpass123'; + + $handler = new UserHandler($payload); + + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'handleCreateUser', ['testuser', 1]), + "User 'testuser' already exists." + ); + } + + public function testHandleCreateUserNoPassword(): void { + $payload = new Payload(); + $payload->type = 'create'; + $payload->username = 'testuser'; + + $handler = new UserHandler($payload); + + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'handleCreateUser', ['testuser', 0]), + 'Password is required for CREATE USER.' + ); + } + + public function testHandleDropUserSuccess(): void { + $payload = new Payload(); + $payload->type = 'drop'; + $payload->username = 'testuser'; + + $handler = new UserHandler($payload); + $clientMock = $this->createSequentialClientMock( + [ + AuthTestHelpers::createEmptySuccessResponse(), // DELETE from perms + AuthTestHelpers::createEmptySuccessResponse(), // DELETE from users + ] + ); + + $this->injectClientMock($handler, $clientMock); + + $result = self::invokeMethod($handler, 'handleDropUser', ['testuser', 1]); + $this->assertInstanceOf(TaskResult::class, $result); + $struct = $result->getStruct(); + $this->assertIsArray($struct); + $this->assertEmpty($struct[0]['data'] ?? []); + } + + public function testHandleDropUserDoesNotExist(): void { + $payload = new Payload(); + $payload->type = 'drop'; + $payload->username = 'nonexistent'; + + $handler = new UserHandler($payload); + + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'handleDropUser', ['nonexistent', 0]), + "User 'nonexistent' does not exist." + ); + } + + public function testValidateUsername(): void { + $payload = new Payload(); + $handler = new UserHandler($payload); + + // Test valid usernames + $validUsernames = ['user1', 'test_user', 'user-name', 'user.name', 'a']; + foreach ($validUsernames as $username) { + // Should not throw exception + self::invokeMethod($handler, 'validateUsername', [$username]); + $this->assertTrue(true); // If we get here, validation passed + } + + // Test invalid usernames + $invalidUsernames = [ + ['', 'Username cannot be empty.'], + [str_repeat('a', 65), 'Username is too long (max 64 characters).'], + ['user@name', 'Username contains invalid characters.'], + ['user name', 'Username contains invalid characters.'], + ['user#name', 'Username contains invalid characters.'], + ]; + + foreach ($invalidUsernames as [$username, $expectedMessage]) { + $this->assertGenericError( + fn() => self::invokeMethod($handler, 'validateUsername', [$username]), + $expectedMessage + ); + } + } + + public function testGenerateToken(): void { + $payload = new Payload(); + $handler = new UserHandler($payload); + + $token1 = self::invokeMethod($handler, 'generateToken'); + $token2 = self::invokeMethod($handler, 'generateToken'); + + // Tokens should be strings + $this->assertIsString($token1); + $this->assertIsString($token2); + + // Tokens should be different (very high probability) + $this->assertNotEquals($token1, $token2); + + // Tokens should be hex strings of correct length (64 chars = 32 bytes * 2) + $this->assertEquals(64, strlen($token1)); + $this->assertTrue(ctype_xdigit($token1)); + } +} diff --git a/test/src/Helper/AuthTestHelpers.php b/test/src/Helper/AuthTestHelpers.php new file mode 100644 index 00000000..de2bed44 --- /dev/null +++ b/test/src/Helper/AuthTestHelpers.php @@ -0,0 +1,154 @@ +> $permissions Array of permission records + * @return Response + */ + public static function createPermissionResponse(array $permissions): Response { + return Response::fromBody( + (string)json_encode( + [ + [ + 'data' => $permissions, + 'columns' => [ + ['Username' => 'Username'], + ['action' => 'action'], + ['Target' => 'Target'], + ['Allow' => 'Allow'], + ['Budget' => 'Budget'], + ], + 'total' => sizeof($permissions), + ], + ] + ) + ); + } + + /** + * Create a response for user existence check + * + * @param bool $userExists Whether the user exists + * @return Response + */ + public static function createUserExistsResponse(bool $userExists): Response { + return Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => $userExists ? 1 : 0]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ); + } + + /** + * Create a response for permission existence check + * + * @param bool $permissionExists Whether the permission exists + * @return Response + */ + public static function createPermissionExistsResponse(bool $permissionExists): Response { + return Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['c' => $permissionExists ? 1 : 0]], + 'columns' => [['c' => 'count(*)']], + 'total' => 1, + ], + ] + ) + ); + } + + /** + * Create an empty success response (for INSERT/UPDATE/DELETE operations) + * + * @return Response + */ + public static function createEmptySuccessResponse(): Response { + return Response::fromBody((string)json_encode([])); + } + + /** + * Create an error response + * + * @param string $errorMessage The error message + * @return Response + */ + public static function createErrorResponse(string $errorMessage): Response { + return Response::fromBody( + (string)json_encode( + [ + 'error' => $errorMessage, + ] + ) + ); + } + + /** + * Create a response for user data queries (with hashes) + * + * @param string $salt The salt value + * @param string $hashesJson JSON string of password hashes + * @return Response + */ + public static function createUserDataResponse(string $salt, string $hashesJson): Response { + return Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [['salt' => $salt, 'hashes' => $hashesJson]], + 'columns' => [ + ['salt' => 'salt'], + ['hashes' => 'hashes'], + ], + 'total' => 1, + ], + ] + ) + ); + } + + /** + * Create a response for empty user data (user not found) + * + * @return Response + */ + public static function createEmptyUserDataResponse(): Response { + return Response::fromBody( + (string)json_encode( + [ + [ + 'data' => [], + 'columns' => [ + ['salt' => 'salt'], + ['hashes' => 'hashes'], + ], + 'total' => 0, + ], + ] + ) + ); + } +} diff --git a/test/src/Trait/AuthTestTrait.php b/test/src/Trait/AuthTestTrait.php new file mode 100644 index 00000000..3ba58470 --- /dev/null +++ b/test/src/Trait/AuthTestTrait.php @@ -0,0 +1,147 @@ +getProperty('manticoreClient'); + $property->setAccessible(true); + $property->setValue($handler, $client); + } + + /** + * Create a mock Response with Struct data + * + * @param array $data The data to include in the response + * @param bool $hasError Whether the response should indicate an error + * @param string $errorMessage Error message if hasError is true + * @return MockObject|Response + */ + protected function createStructResponse( + array $data, + bool $hasError = false, + string $errorMessage = '' + ): MockObject|Response { + $response = $this->createMock(Response::class); + $response->method('getResult')->willReturn(Struct::fromData([['data' => $data]])); + $response->method('hasError')->willReturn($hasError); + + if ($hasError) { + $response->method('getError')->willReturn($errorMessage); + } + + return $response; + } + + /** + * Create a mock Response for user existence check + * + * @param bool $userExists Whether the user exists + * @return MockObject|Response + */ + protected function createUserExistsResponse(bool $userExists): MockObject|Response { + return $this->createStructResponse( + [['c' => $userExists ? 1 : 0]], + false + ); + } + + /** + * Create a mock Response for permission existence check + * + * @param bool $permissionExists Whether the permission exists + * @return MockObject|Response + */ + protected function createPermissionExistsResponse(bool $permissionExists): MockObject|Response { + return $this->createStructResponse( + [['c' => $permissionExists ? 1 : 0]], + false + ); + } + + /** + * Create an empty success response (for INSERT/UPDATE/DELETE operations) + * + * @return MockObject|Response + */ + protected function createEmptySuccessResponse(): MockObject|Response { + $response = $this->createMock(Response::class); + $response->method('getResult')->willReturn(Struct::fromData([['data' => []]])); + $response->method('hasError')->willReturn(false); + return $response; + } + + /** + * Create an error response + * + * @param string $errorMessage The error message + * @return MockObject|Response + */ + protected function createErrorResponse(string $errorMessage): MockObject|Response { + $response = $this->createMock(Response::class); + $response->method('hasError')->willReturn(true); + $response->method('getError')->willReturn($errorMessage); + return $response; + } + + /** + * Assert that a GenericError is thrown with the expected message + * + * @param callable $testCode The code that should throw the exception + * @param string $expectedMessage The expected error message + */ + protected function assertGenericError(callable $testCode, string $expectedMessage): void { + try { + $testCode(); + $this->fail('Expected GenericError to be thrown'); + } catch (GenericError $e) { + $this->assertEquals($expectedMessage, $e->getResponseError()); + } + } + + /** + * Create a mock HTTP client that returns a sequence of responses + * + * @param array $responses The responses to return in sequence + * @return MockObject|HTTPClient + */ + protected function createSequentialClientMock(array $responses): MockObject|HTTPClient { + $clientMock = $this->createMock(HTTPClient::class); + $responseIndex = 0; + + $clientMock->method('sendRequest') + ->willReturnCallback( + function () use ($responses, &$responseIndex) { + if ($responseIndex < sizeof($responses)) { + return $responses[$responseIndex++]; + } + return $this->createEmptySuccessResponse(); + } + ); + + return $clientMock; + } +}