Restructure rules, rewrite config, starts tests

Rules now distinguish between user errors and bugs, give more specific errors, configuration now uses a completely different interface, and start with the new setup for tests.
This commit is contained in:
Florine W. Dekker 2022-12-07 23:12:33 +01:00
parent 2ef6b105ce
commit 732298bf09
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
41 changed files with 499 additions and 647 deletions

View File

@ -1,7 +1,7 @@
{
"name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.",
"version": "0.18.2", "_comment_version": "Also update version in `package.json`!",
"version": "0.19.0", "_comment_version": "Also update version in `package.json`!",
"type": "project",
"license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier",

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.18.2", "_comment_version": "Also update version in `composer.json`!",
"version": "0.19.0", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

@ -45,7 +45,7 @@ try {
session_start();
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token();
$db = new Database(Config::get()["database"]["filename"]);
$db = new Database(Config::get("database.filename"));
$wikipedia = new Wikipedia();
$email_queue = new EmailQueue($db);

View File

@ -4,7 +4,7 @@
[admin]
# bcrypt hash of password to use the CLI of `api.php`. If set to its default value, or if empty, the CLI is disabled.
cli_secret = REPLACE THIS WITH A SECRET VALUE
cli_password = REPLACE THIS WITH A SECRET VALUE
[database]
# Relative path to SQLite database.

View File

@ -9,9 +9,8 @@ use InvalidArgumentException;
/**
* The application's configuration, read lazily from configuration files.
*
* Contains global state, but that's fine since it's read-only.
* Contains global state, but that's fine since it's read-only (except for some internal functions for testing).
*/
// TODO: Override dynamically from tests
class Config
{
/**
@ -21,16 +20,88 @@ class Config
/**
* Returns the application's configuration.
* Returns the property at {@see $index}.
*
* @return array<string, mixed> the application's configuration
* To get the property `name` from section `section`, set `$index` to `"name.section"`. To return the entire section
* `section` as an array, set `$index` to `"section"`.
*
* @param string|null $index the index of the property to return, using `.` as a nesting delimiter; or `null` to
* return the entire configuration
* @return mixed the property at {@see $index}
*/
public static function get(): array
public static function get(?string $index = null): mixed
{
if (self::$config === null)
self::$config = self::read_config();
self::$config = self::read_config_from_file();
return self::$config;
if ($index === null)
return self::$config;
$output = self::$config;
foreach (explode(".", $index) as $key)
$output = $output[$key];
return $output;
}
/**
* Returns `true` if and only if the property at {@see $index} is not `null`.
*
* @param string $index the index of the property to check, using `.` as a nesting delimiter
* @return bool `true` if and only if the property at {@see $index} is not `null`
*/
public static function has(string $index): bool
{
$output = self::get();
foreach (explode(".", $index) as $key) {
if (!isset($output[$key]))
return false;
$output = $output[$key];
}
return true;
}
/**
* Sets the property at {@see $index} to {@see $value}.
*
* This function is intended for testability. It should never be used in production code.
*
* @param string $index the index to set to {@see $value}, using `.` as a nesting delimiter
* @param mixed $value the value to write at {@see $index}
* @return void
* @see Config::_reset()
*/
public static function _set(string $index, mixed $value): void
{
self::get();
$keys = explode(".", $index);
$last_key = array_pop($keys);
$output = &self::$config;
foreach ($keys as $key) {
if (!isset($output[$key]))
$output[$key] = [];
$output = &$output[$key];
}
$output[$last_key] = $value;
}
/**
* Resets all manual modifications to the application's configuration.
*
* This function is intended for testability. It should never be used in production code.
*
* @return void
* @see Config::_set()
*/
public static function _reset(): void
{
self::$config = null;
}
@ -39,7 +110,7 @@ class Config
*
* @return array<string, mixed> the application's configuration
*/
private static function read_config(): array
private static function read_config_from_file(): array
{
$config = parse_ini_file("config.default.ini.php", process_sections: true, scanner_mode: INI_SCANNER_TYPED);
if ($config === false) throw new InvalidArgumentException("Invalid `config.default.ini.php` file.");
@ -48,7 +119,7 @@ class Config
$config_custom = parse_ini_file("config.ini.php", process_sections: true, scanner_mode: INI_SCANNER_TYPED);
if ($config_custom === false) throw new InvalidArgumentException("Invalid `config.ini.php` file.");
$config = array_replace_recursive($config, $config_custom);
$config = Util::array_merge_recursive_distinct($config, $config_custom);
// Check file permissions
if (!$config["security"]["allow_config_insecure_permissions"]) {

View File

@ -30,7 +30,7 @@ class LoggerUtil
public static function with_name(string $name = "main"): Logger
{
if (self::$main_logger === null) {
$config = Config::get()["logger"];
$config = Config::get("logger");
self::$main_logger = new Logger("main");
self::$main_logger->pushHandler(new StreamHandler($config["file"], $config["level"]));

View File

@ -40,7 +40,6 @@ class StartSessionAction extends Action
*/
public function handle(array $inputs): array
{
$config = Config::get();
$payload = [];
// Check if logged in
@ -65,8 +64,8 @@ class StartSessionAction extends Action
}
// Read global message
if (isset($config["server"]["global_message"]) && trim($config["server"]["global_message"]) !== "")
$payload["global_message"] = trim($config["server"]["global_message"]);
if (Config::has("server.global_message") && trim(Config::get("server.global_message")) !== "")
$payload["global_message"] = trim(Config::get("server.global_message"));
return $payload;
}

View File

@ -76,4 +76,38 @@ class Util
{
return $interval - ((time() - $timestamp) / 60);
}
/**
* Recursively merges arrays, while overwriting other types.
*
* Functions similar to `array_merge_recursive`, except that if two values are encountered at least one of which is not
* an array, the value of `array2` is taken, instead of taking an array of both values.
*
* If a key exists in `array1` but not in `array2`, then the value of `array1` is used. If a key exists in `array2` but
* not in `array1`, then the value of `array2` is used. If a key exists in both `array1` and `array2`, and both values
* are arrays, this function is applied recursively, effectively using a merged array containing the values of both
* arrays' arrays. If a key exists in both `array1` and `array2`, and at least one of the values is not an array, the
* value of `array2` is used.
*
* Taken from `https://www.php.net/manual/en/function.array-merge-recursive.php#92195`.
*
* @param mixed[] $array1 the base array to merge into
* @param mixed[] $array2 the array to merge into `array1`
* @return mixed[] the recursively merged array
* @author Daniel <daniel (at) danielsmedegaardbuus (dot) dk>
* @author Gabriel Sobrinho <gabriel (dot) sobrinho (at) gmail (dot) com>
*/
static function array_merge_recursive_distinct(array $array1, array $array2): array
{
$merged = $array1;
foreach ($array2 as $key => $value)
if (is_array($value) && isset ($merged[$key]) && is_array($merged[$key]))
$merged[$key] = Util::array_merge_recursive_distinct($merged[$key], $value);
else
$merged[$key] = $value;
return $merged;
}
}

View File

@ -5,7 +5,7 @@ namespace com\fwdekker\deathnotifier\mailer;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\UnexpectedException;
use com\fwdekker\deathnotifier\validator\EqualsCliSecretRule;
use com\fwdekker\deathnotifier\validator\EqualsCliPasswordRule;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use com\fwdekker\deathnotifier\validator\RuleSet;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
@ -45,7 +45,7 @@ class ProcessEmailQueueAction extends Action
*/
public function handle(array $inputs): mixed
{
(new RuleSet(["password" => [new EqualsCliSecretRule()]]))->check($inputs);
(new RuleSet(["password" => [new EqualsCliPasswordRule()]]))->check($inputs);
$mailer = $this->create_mailer();
$emails = $this->email_queue->get_queue();
@ -76,7 +76,7 @@ class ProcessEmailQueueAction extends Action
*/
private function create_mailer(): PHPMailer
{
$config = Config::get();
$config = Config::get("mail");
$mailer = new PHPMailer();
$mailer->IsSMTP();
@ -86,12 +86,12 @@ class ProcessEmailQueueAction extends Action
$mailer->SMTPDebug = SMTP::DEBUG_OFF;
$mailer->SMTPKeepAlive = true;
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mailer->Host = $config["mail"]["host"];
$mailer->Port = $config["mail"]["port"];
$mailer->Username = $config["mail"]["username"];
$mailer->Password = $config["mail"]["password"];
$mailer->Host = $config["host"];
$mailer->Port = $config["port"];
$mailer->Username = $config["username"];
$mailer->Password = $config["password"];
try {
$mailer->setFrom($config["mail"]["username"], $config["mail"]["from_name"]);
$mailer->setFrom($config["username"], $config["from_name"]);
} catch (PHPMailerException $exception) {
$mailer->smtpClose();
throw new UnexpectedException(

View File

@ -8,7 +8,7 @@ use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\EmailQueue;
use com\fwdekker\deathnotifier\UnexpectedException;
use com\fwdekker\deathnotifier\validator\EqualsCliSecretRule;
use com\fwdekker\deathnotifier\validator\EqualsCliPasswordRule;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use com\fwdekker\deathnotifier\validator\RuleSet;
use com\fwdekker\deathnotifier\wikipedia\Wikipedia;
@ -70,7 +70,7 @@ class UpdateTrackingsAction extends Action
*/
public function handle(array $inputs): mixed
{
(new RuleSet(["password" => [new EqualsCliSecretRule()]]))->check($inputs);
(new RuleSet(["password" => [new EqualsCliPasswordRule()]]))->check($inputs);
// Process changes
$new_deletions = [];
@ -165,7 +165,7 @@ class NotifyStatusChangedEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
return
"Someone has edited Wikipedia to state that $this->name is $this->new_status. " .
@ -219,7 +219,7 @@ class NotifyArticleDeletedEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
return
"The Wikipedia article about $this->name has been deleted. " .
@ -274,7 +274,7 @@ class NotifyArticleUndeletedEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
return
"The Wikipedia article about $this->name has been re-created. " .

View File

@ -126,7 +126,7 @@ class ChangeEmailFromEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
return
"You changed the email address of your Death Notifier account from $this->recipient to $this->new_email. " .
@ -186,7 +186,7 @@ class ChangeEmailToEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return

View File

@ -114,7 +114,7 @@ class ChangePasswordEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
return
"You changed the password of your Death Notifier account." .

View File

@ -115,7 +115,7 @@ class RegisterEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return

View File

@ -127,7 +127,7 @@ class ResendVerifyEmailEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return

View File

@ -118,7 +118,7 @@ class ResetPasswordEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
return
"You requested a password reset and changed the password of your Death Notifier account." .

View File

@ -125,7 +125,7 @@ class SendPasswordResetEmail extends Email
public function get_body(): string
{
$base_path = Config::get()["server"]["base_path"];
$base_path = Config::get("server.base_path");
$verify_path =
"$base_path?action=reset-password&email=" . rawurlencode($this->recipient) . "&token=$this->token";

View File

@ -0,0 +1,48 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\IllegalArgumentError;
/**
* Validates that the input equals the CLI password.
*/
class EqualsCliPasswordRule extends Rule
{
/**
* The key in the configuration at which the hash of the CLI password is stored.
*/
public const CONFIG_KEY = "admin.cli_password";
/**
* The default value of the CLI password.
*/
public const DEFAULT = "REPLACE THIS WITH A SECRET VALUE";
/**
* Validates that the input equals the CLI password.
*
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
* @param string $key the key in {@see $inputs} of the input to check
* @return void if the checked input equals the CLI password
* @throws InvalidInputException if the checked input does not equal the CLI password
* @throws IllegalArgumentError if the CLI password is a blank string, if the CLI password is at its default value,
* if the checked input is not set
*/
public function check(array $inputs, string $key): void
{
if (!Config::has(self::CONFIG_KEY) || trim(Config::get(self::CONFIG_KEY)) === "")
throw new IllegalArgumentError("The CLI is disabled because the CLI password is not set.");
if (Config::get(self::CONFIG_KEY) === self::DEFAULT)
throw new IllegalArgumentError("The CLI is disabled because the CLI password is set to the default.");
if (!isset($inputs[$key]))
throw new InvalidInputException("This operation requires the CLI password.", $key);
if (!password_verify($inputs[$key], Config::get(self::CONFIG_KEY)))
throw new InvalidInputException("Incorrect CLI password.", $key);
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\Config;
/**
* Validates that the input equals the CLI secret.
*/
class EqualsCliSecretRule extends Rule
{
/**
* The default value of the CLI secret.
*/
private const CLI_SECRET_DEFAULT = "REPLACE THIS WITH A SECRET VALUE";
/**
* Validates that the input equals the CLI secret.
*
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
* @param string $key the key in {@see $inputs} of the input to check
* @return void if the checked input equals the CLI secret
* @throws InvalidInputException if the checked input is not set, the CLI secret is a blank string, the CLI secret
* is at its default value, or if the checked input does not equal the CLI secret
*/
public function check(array $inputs, string $key): void
{
$cli_password = Config::get()["admin"]["cli_secret"];
if (trim($cli_password) === "" || $cli_password === self::CLI_SECRET_DEFAULT)
throw new InvalidInputException(
$this->override_message ?? "The CLI is disabled because the CLI secret is not set.",
$key
);
if (!isset($inputs[$key]) || !password_verify($inputs[$key], Config::get()["admin"]["cli_secret"]))
throw new InvalidInputException($this->override_message ?? "Incorrect password.", $key);
}
}

View File

@ -23,13 +23,9 @@ class HasStringLengthRule extends Rule
*
* @param int|null $min_length the minimum length (inclusive), or `null` if there is no minimum length
* @param int|null $max_length the maximum length (inclusive), or `null` if there is no maximum length
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
* the rule implementation can choose an appropriate message
*/
public function __construct(?int $min_length = null, ?int $max_length = null, ?string $override_message = null)
public function __construct(?int $min_length = null, ?int $max_length = null)
{
parent::__construct($override_message);
$this->min_length = $min_length;
$this->max_length = $max_length;
}
@ -46,20 +42,13 @@ class HasStringLengthRule extends Rule
public function check(array $inputs, string $key): void
{
if (!isset($inputs[$key]))
throw new InvalidInputException($this->override_message ?? "Missing input '$key'.", $key);
throw new InvalidInputException("Missing input '$key'.", $key);
if (!is_string($inputs[$key]))
throw new InvalidInputException($this->override_message ?? "Field must be a string.", $key);
throw new InvalidInputException("Field must be a string.", $key);
if ($this->min_length !== null && strlen($inputs[$key]) < $this->min_length)
throw new InvalidInputException(
$this->override_message ?? "Use at least $this->min_length character(s).",
$key
);
throw new InvalidInputException("Use at least $this->min_length character(s).", $key);
if ($this->max_length !== null && strlen($inputs[$key]) > $this->max_length)
throw new InvalidInputException(
$this->override_message ?? "Use at most $this->max_length character(s).",
$key
);
throw new InvalidInputException("Use at most $this->max_length character(s).", $key);
}
}

View File

@ -19,9 +19,6 @@ class IsBooleanRule extends Rule
public function check(array $inputs, string $key): void
{
if (!isset($inputs[$key]) || !is_bool($inputs[$key]))
throw new InvalidInputException(
$this->override_message ?? "Field '" . htmlentities($key) . "' must be a boolean.",
$key
);
throw new InvalidInputException("Field '" . htmlentities($key) . "' must be a boolean.", $key);
}
}

View File

@ -2,6 +2,8 @@
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\IllegalArgumentError;
/**
* Validates that the input is an email address.
@ -14,11 +16,28 @@ class IsEmailRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
* @param string $key the key in {@see $inputs} of the input to check
* @return void if the checked input is an email address
* @throws InvalidInputException if the checked input is not set, is not a string, or is not an email address
* @throws InvalidInputException if the checked input is not an email address
* @throws IllegalArgumentError if the checked input is not set or is not a string
*/
public function check(array $inputs, string $key): void
{
if (!isset($inputs[$key]) || !is_string($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL))
throw new InvalidInputException($this->override_message ?? "Enter a valid email address.", $key);
if (!isset($inputs[$key]))
throw new IllegalArgumentError("Required input '$key' not set.");
if (!is_string($inputs[$key]))
throw new IllegalArgumentError("Input '$key' should be string, but is " . gettype($inputs[$key]) . ".");
$input = $inputs[$key];
if (str_starts_with($input, " "))
throw new InvalidInputException("Remove the spaces at the start.", $key);
if (str_ends_with($input, " "))
throw new InvalidInputException("Remove the spaces at the end.", $key);
if ($input === "")
throw new InvalidInputException("Enter an email address.", $key);
if (!str_contains($input, "@"))
throw new InvalidInputException("Don't forget to add an '@' symbol.", $key);
if (str_ends_with($input, "@"))
throw new InvalidInputException("Add a domain name after the '@'.", $key);
if (!filter_var($input, FILTER_VALIDATE_EMAIL))
throw new InvalidInputException("Enter a valid email address.", $key);
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
/**
* Validates that the input has the specified value.
*/
class IsEqualToRule extends Rule
{
/**
* @var string the required value
*/
private readonly string $expected;
/**
* Constructs a new `IsEqualToRule`.
*
* @param string $expected the value that checked values should be equal to
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
* the rule implementation can choose an appropriate message
*/
public function __construct(string $expected, ?string $override_message = null)
{
parent::__construct($override_message);
$this->expected = $expected;
}
/**
* Validates that the input has the specified value.
*
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
* @param string $key the key in {@see $inputs} of the input to check
* @return void if the checked input equals {@see $expected}
* @throws InvalidInputException if the checked input is not set or does not equal {@see $expected}
*/
public function check(array $inputs, string $key): void
{
if (!isset($inputs[$key]) || $inputs[$key] !== $this->expected)
throw new InvalidInputException(
$this->override_message ?? "Input '$key' should equal '$this->expected'.",
$key
);
}
}

View File

@ -19,6 +19,6 @@ class IsNotBlankRule extends Rule
public function check(array $inputs, string $key): void
{
if (!isset($inputs[$key]) || !is_string($inputs[$key]) || trim($inputs[$key]) === "")
throw new InvalidInputException($this->override_message ?? "Use at least one character.", $key);
throw new InvalidInputException("Use at least one character.", $key);
}
}

View File

@ -8,6 +8,24 @@ namespace com\fwdekker\deathnotifier\validator;
*/
class IsNotSetRule extends Rule
{
/**
* @var string|null the message to return if the input is not set, or `null` to show the default message
*/
public readonly ?string $override_message;
/**
* Instantiates a new `IsSetRule`.
*
* @param string|null $override_message the message to return if the input is not set, or `null` to show the default
* message
*/
public function __construct(?string $override_message = null)
{
$this->override_message = $override_message;
}
/**
* Validates that the input is not set.
*

View File

@ -8,6 +8,24 @@ namespace com\fwdekker\deathnotifier\validator;
*/
class IsSetRule extends Rule
{
/**
* @var string|null the message to return if the input is not set, or `null` to show the default message
*/
public readonly ?string $override_message;
/**
* Instantiates a new `IsSetRule`.
*
* @param string|null $override_message the message to return if the input is not set, or `null` to show the default
* message
*/
public function __construct(?string $override_message = null)
{
$this->override_message = $override_message;
}
/**
* Validates that the input is set.
*

View File

@ -19,9 +19,6 @@ class IsStringRule extends Rule
public function check(array $inputs, string $key): void
{
if (!isset($inputs[$key]) || !is_string($inputs[$key]))
throw new InvalidInputException(
$this->override_message ?? "Field '" . htmlentities($key) . "' must be a string.",
$key
);
throw new InvalidInputException("Field '" . htmlentities($key) . "' must be a string.", $key);
}
}

View File

@ -4,21 +4,21 @@ namespace com\fwdekker\deathnotifier\validator;
/**
* Validates that the input equals the CLI password.
* Validates that the input is a valid CSRF token.
*/
class IsValidCsrfTokenRule extends IsEqualToRule
class IsValidCsrfTokenRule extends Rule
{
/**
* Constructs a new `IsValidCsrfTokenRule`.
* Validates that the input is a valid CSRF token.
*
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
* the rule implementation can choose an appropriate message
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
* @param string $key the key in {@see $inputs} of the input to check
* @return void if the checked input is a valid CSRF token
* @throws InvalidInputException if the checked input is not a valid CSRF token
*/
public function __construct(?string $override_message = null)
public function check(array $inputs, string $key): void
{
parent::__construct(
$_SESSION["token"],
$override_message ?? "Invalid request token. Please refresh the page and try again."
);
if (!isset($inputs[$key]) || $inputs[$key] !== $_SESSION["token"])
throw new InvalidInputException("Invalid request token. Please refresh the page and try again.", $key);
}
}

View File

@ -2,40 +2,22 @@
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\IllegalArgumentError;
/**
* A rule/constraint/assertion that should hold over an input.
*/
abstract class Rule
{
/**
* @var string|null the message to return if the rule does not apply to some input. If `null`, the rule
* implementation can choose an appropriate message.
*/
public readonly ?string $override_message;
/**
* Instantiates a new rule.
*
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
* the rule implementation can choose an appropriate message
*/
public function __construct(?string $override_message = null)
{
$this->override_message = $override_message;
}
/**
* Checks whether the rule holds for `$inputs[$key]`.
*
* Implementations should never assume that the `$inputs[$key]` is set.
*
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
* @param string $key the key in `inputs` of the input to check
* @return void if the rule holds
* @throws InvalidInputException if the rule does not hold
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
* @param string $key the key in {@see $inputs} of the input to check
* @return void if the rule holds for the checked input
* @throws InvalidInputException if the rule does not hold for the checked input
* @throws IllegalArgumentError if the checked input is not set or of the wrong type
*/
public abstract function check(array $inputs, string $key): void;
}

View File

@ -24,9 +24,11 @@ class SessionRuleSet extends RuleSet
$rules = [];
if ($validate_logged_in)
$rules["uuid"] = [new IsSetRule("You must be logged in to perform this action.")];
$rules["uuid"] =
[new IsSetRule("You must be logged in to perform this action. Refresh the page and try again.")];
if ($validate_logged_out)
$rules["uuid"] = [new IsNotSetRule("You must be logged out to perform this action.")];
$rules["uuid"] =
[new IsNotSetRule("You must be logged out to perform this action. Refresh the page and try again.")];
parent::__construct($rules);
}

View File

@ -2,6 +2,7 @@
namespace com\fwdekker\deathnotifier\wikipedia;
use com\fwdekker\deathnotifier\Util;
use JsonException;
@ -81,7 +82,7 @@ class Wikipedia
: array_merge($query_params, ["continue" => $continue, $continue_name => $continue_value]);
$new_response = $this->api_fetch($continue_params);
$response = array_merge_recursive_distinct($response, $new_response);
$response = Util::array_merge_recursive_distinct($response, $new_response);
if (isset($response["batchcomplete"])) {
$continue = null;
@ -219,37 +220,3 @@ class Wikipedia
return new QueryOutput($articles, $output->redirects, $output->missing);
}
}
/**
* Recursively merges arrays, while overwriting other types.
*
* Functions similar to `array_merge_recursive`, except that if two values are encountered at least one of which is not
* an array, the value of `array2` is taken, instead of taking an array of both values.
*
* If a key exists in `array1` but not in `array2`, then the value of `array1` is used. If a key exists in `array2` but
* not in `array1`, then the value of `array2` is used. If a key exists in both `array1` and `array2`, and both values
* are arrays, this function is applied recursively, effectively using a merged array containing the values of both
* arrays' arrays. If a key exists in both `array1` and `array2`, and at least one of the values is not an array, the
* value of `array2` is used.
*
* Taken from `https://www.php.net/manual/en/function.array-merge-recursive.php#92195`.
*
* @param mixed[] $array1 the base array to merge into
* @param mixed[] $array2 the array to merge into `array1`
* @return mixed[] the recursively merged array
* @author Daniel <daniel (at) danielsmedegaardbuus (dot) dk>
* @author Gabriel Sobrinho <gabriel (dot) sobrinho (at) gmail (dot) com>
*/
function array_merge_recursive_distinct(array $array1, array $array2): array
{
$merged = $array1;
foreach ($array2 as $key => $value)
if (is_array($value) && isset ($merged[$key]) && is_array($merged[$key]))
$merged[$key] = array_merge_recursive_distinct($merged[$key], $value);
else
$merged[$key] = $value;
return $merged;
}

View File

@ -0,0 +1,103 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\Config;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for {@see Config}.
*/
class ConfigTest extends TestCase
{
public function setUp(): void
{
Config::_reset();
}
public function test_has_returns_false_if_section_does_not_exist(): void
{
self::assertFalse(Config::has("test_section"));
}
public function test_has_returns_false_if_property_does_not_exist(): void
{
self::assertFalse(Config::has("test_section.property"));
}
public function test_set_creates_section(): void
{
Config::_set("test_section", "value");
self::assertTrue(Config::has("test_section"));
}
public function test_set_creates_property_in_section(): void
{
Config::_set("test_section.property", "value");
self::assertTrue(Config::has("test_section.property"));
}
public function test_set_creates_section_for_property(): void
{
Config::_set("test_section.property", "value");
self::assertTrue(Config::has("test_section"));
}
public function test_set_adds_property_to_existing_section(): void
{
Config::_set("test_section.property1", "value");
Config::_set("test_section.property2", "value");
self::assertTrue(Config::has("test_section.property1"));
self::assertTrue(Config::has("test_section.property2"));
}
public function test_set_overwrites_existing_property(): void
{
Config::_set("test_section.property", "value");
Config::_set("test_section.property", "new_value");
self::assertEquals("new_value", Config::get("test_section.property"));
}
public function test_get_returns_all_with_null_index(): void
{
Config::_set("test_section.property", "value");
$config = Config::get();
self::assertArrayHasKey("test_section", $config);
self::assertIsArray($config["test_section"]);
}
public function test_get_returns_section(): void
{
Config::_set("test_section.property", "value");
self::assertEquals(["property" => "value"], Config::get("test_section"));
}
public function test_get_returns_property(): void
{
Config::_set("test_section.property", "value");
self::assertEquals("value", Config::get("test_section.property"));
}
public function test_reset(): void
{
Config::_set("test_section.property", "value");
Config::_reset();
self::assertFalse(Config::has("test_section.property"));
}
}

View File

@ -6,7 +6,7 @@ use com\fwdekker\deathnotifier\mailer\EmailQueue;
use com\fwdekker\deathnotifier\tracking\TrackingList;
use com\fwdekker\deathnotifier\user\UserList;
use Exception;
use Monolog\Test\TestCase;
use PHPUnit\Framework\TestCase;
/**

View File

@ -0,0 +1,68 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\IllegalArgumentError;
use PHPUnit\Framework\TestCase;
use Throwable;
/**
* Unit tests for {@see EqualsCliPasswordRule}.
*/
class EqualsCliPasswordRuleTest extends TestCase
{
/**
* Tests the output of {@see EqualsCliPasswordRule::check()}.
*
* @param string $name the name to give to the test case
* @param string|null $password the password that is set in the configuration
* @param string|null $input the user's input
* @param class-string<Throwable>|null $exception the exception that is asserted to be thrown
* @param string|null $exception_message the exception message that is asserted
* @return void
* @throws InvalidInputException if {@see $exception} is `null` but an exception is thrown
* @dataProvider check_provider
*/
public function test_check(string $name, ?string $password, ?string $input, ?string $exception,
?string $exception_message): void
{
self::setName($name);
if ($exception !== null)
self::expectException($exception);
if ($exception_message !== null)
self::expectExceptionMessage($exception_message);
Config::_set(EqualsCliPasswordRule::CONFIG_KEY, $password);
(new EqualsCliPasswordRule())->check(["key" => $input], "key");
Config::_reset();
if ($exception === null && $exception_message === null)
self::assertTrue(true);
}
/**
* Returns the test cases.
*
* @return array<array{string, string|null, string|null, class-string<Throwable>|null, string|null}> the test cases
* @see RuleTest::test_check()
*/
public function check_provider(): array
{
$hash = "\$2y\$04\$fwXTw7Rjzw0EpU094u4agOBaBNqtCHGc4TMoxfbPrxuqO5tpYyRka"; # Hash of "password"
$error = IllegalArgumentError::class;
$exception = InvalidInputException::class;
return [
["error if password is not set", null, "input", $error, "The CLI is disabled because the CLI password is not set."],
["error if password is blank", " ", "input", $error, "The CLI is disabled because the CLI password is not set."],
["error if password is default", EqualsCliPasswordRule::DEFAULT, "input", $error, "The CLI is disabled because the CLI password is set to the default."],
["exception if input is not set", $hash, null, $exception, "This operation requires the CLI password."],
["exception if input is incorrect", $hash, "incorrect", $exception, "Incorrect CLI password."],
["no exception if input is correct", $hash, "password", null, null],
];
}
}

View File

@ -2,44 +2,30 @@
namespace com\fwdekker\deathnotifier\validator;
use com\fwdekker\deathnotifier\IllegalArgumentError;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for `IsEmailRule`.
* Unit tests for {@see IsEmailRule}.
*/
class IsEmailRuleTest extends RuleTest
{
function get_rule(?string $override = null): Rule
public function check_provider(): array
{
return new IsEmailRule($override);
}
$error = IllegalArgumentError::class;
$exception = InvalidInputException::class;
function get_valid_input(): ?string
{
return "test@test.test";
}
function get_invalid_input(): ?string
{
return "invalid";
}
public function test_returns_null_if_email_is_valid(): void
{
$rule = new IsEmailRule();
$is_valid = $rule->check(["email" => "example@example.com"], "email");
self::assertNull($is_valid);
}
public function test_returns_response_message_if_email_is_invalid(): void
{
$rule = new IsEmailRule();
$is_valid = $rule->check(["email" => "example.com"], "email");
self::assertNotNull($is_valid);
self::assertEquals("Enter a valid email address.", $is_valid->payload["message"]);
return [
["error if input is not set", null, $error, "Required input 'key' not set."],
["error if input is not string", 491, $error, "Input 'key' should be string, but is integer."],
["exception if input starts with space", " valid@example.com", $exception, "Remove the spaces at the start."],
["exception if input ends with space", "valid@example.com ", $exception, "Remove the spaces at the end."],
["exception if input is empty", "", $exception, "Enter an email address."],
["exception if input misses the '@' symbol", "invalid", $exception, "Don't forget to add an '@' symbol."],
["exception if input misses the domain part", "invalid@", $exception, "Add a domain name after the '@'."],
["exception if input is invalid in a complex way", "invalid@example", $exception, "Enter a valid email address."],
["no exception if input is valid", "valid@example.com", null, null],
];
}
}

View File

@ -1,55 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
/**
* Unit tests for `IsNotBlankRule`.
*/
class IsNotBlankRuleTest extends RuleTest
{
function get_rule(?string $override = null): Rule
{
return new IsNotBlankRule($override);
}
function get_valid_input(): ?string
{
return "not-blank";
}
function get_invalid_input(): ?string
{
return "";
}
public function test_returns_null_if_string_is_not_blank(): void
{
$rule = new IsNotBlankRule();
$is_valid = $rule->check(["input" => "not-blank"], "input");
self::assertNull($is_valid);
}
public function test_returns_response_message_if_input_is_the_empty_string(): void
{
$rule = new IsNotBlankRule();
$is_valid = $rule->check(["input" => ""], "input");
self::assertNotNull($is_valid);
self::assertEquals("Use at least one character.", $is_valid->payload["message"]);
}
public function test_returns_response_message_if_input_contains_whitespace_only(): void
{
$rule = new IsNotBlankRule();
$is_valid = $rule->check(["input" => " "], "input");
self::assertNotNull($is_valid);
self::assertEquals("Use at least one character.", $is_valid->payload["message"]);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
/**
* Unit tests for `IsSetRule`.
*/
class IsSetRuleTest extends RuleTest
{
function get_rule(?string $override = null): Rule
{
return new IsSetRule($override);
}
function get_valid_input(): ?string
{
return "is-set";
}
function get_invalid_input(): ?string
{
return null;
}
}

View File

@ -1,71 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
/**
* Unit tests for `HasLengthRule`.
*/
class LengthRuleTest extends RuleTest
{
function get_rule(?string $override = null): Rule
{
return new HasStringLengthRule(1, 6, $override);
}
function get_valid_input(): ?string
{
return "valid";
}
function get_invalid_input(): ?string
{
return "too-long";
}
public function test_returns_null_if_input_is_exactly_minimum_length(): void
{
$rule = new HasStringLengthRule(1, 3);
$is_valid = $rule->check(["input" => "a"], "input");
self::assertNull($is_valid);
}
public function test_returns_null_if_input_is_exactly_maximum_length(): void
{
$rule = new HasStringLengthRule(1, 3);
$is_valid = $rule->check(["input" => "123"], "input");
self::assertNull($is_valid);
}
public function test_returns_null_if_input_is_strictly_inside_range(): void
{
$rule = new HasStringLengthRule(1, 3);
$is_valid = $rule->check(["input" => "12"], "input");
self::assertNull($is_valid);
}
public function test_returns_not_null_if_input_is_strictly_below_minimum(): void
{
$rule = new HasStringLengthRule(1, 3);
$is_valid = $rule->check(["input" => ""], "input");
self::assertNotNull($is_valid);
}
public function test_returns_not_null_if_input_is_strictly_above_maximum(): void
{
$rule = new HasStringLengthRule(1, 3);
$is_valid = $rule->check(["input" => "1234"], "input");
self::assertNotNull($is_valid);
}
}

View File

@ -2,97 +2,47 @@
namespace com\fwdekker\deathnotifier\validator;
use Monolog\Test\TestCase;
use com\fwdekker\deathnotifier\IllegalArgumentError;
use PHPUnit\Framework\TestCase;
use Throwable;
/**
* Inheritable unit tests for extensions of `Rule`.
* Unit tests for {@see Rule} implementations.
*/
abstract class RuleTest extends TestCase
{
/**
* Constructs a new `Rule` of the class under test, using `override`.
* Tests the output of {@see Rule::check()}.
*
* The returned `Rule` will only be tested under `get_valid_input` and `get_invalid_input`.
*
* @param string|null $override the override message
* @return Rule a new `Rule` of the class under test, using `override`
* @param string $name the name to give to the test case
* @param mixed|null $input the user's input
* @param class-string<Throwable>|null $exception the exception that is asserted to be thrown
* @param string|null $exception_message the exception message that is asserted
* @return void
* @throws InvalidInputException if {@see $exception} is `null` but an exception is thrown
* @dataProvider check_provider
*/
abstract function get_rule(?string $override = null): Rule;
public function test_check(string $name, mixed $input, ?string $exception, ?string $exception_message): void
{
self::setName($name);
if ($exception !== null)
self::expectException($exception);
if ($exception_message !== null)
self::expectExceptionMessage($exception_message);
(new IsEmailRule())->check(["key" => $input], "key");
if ($exception === null)
self::assertTrue(true);
}
/**
* @return string|null a valid input to give to `get_rule`
* Returns the test cases.
*
* @return array<array{string, mixed|null, class-string<Throwable>|null, string|null}> the test cases
* @see RuleTest::test_check()
*/
abstract function get_valid_input(): ?string;
/**
* @return string|null an invalid input to give to `get_rule`
*/
abstract function get_invalid_input(): ?string;
public function test_returns_null_if_input_is_valid(): void
{
$rule = $this->get_rule();
$is_valid = $rule->check(["input" => $this->get_valid_input()], "input");
self::assertNull($is_valid);
}
public function test_returns_not_null_if_input_is_invalid(): void
{
$rule = $this->get_rule();
$is_valid = $rule->check(["input" => $this->get_invalid_input()], "input");
self::assertNotNull($is_valid);
}
public function test_returns_not_null_if_input_is_not_set(): void
{
$rule = $this->get_rule();
$is_valid = $rule->check(["input" => $this->get_valid_input()], "does_not_exist");
self::assertNotNull($is_valid);
}
public function test_returns_unsatisfied_payload_if_input_is_invalid(): void
{
$rule = $this->get_rule();
$is_valid = $rule->check(["input" => $this->get_invalid_input()], "input");
self::assertNotNull($is_valid);
self::assertFalse($is_valid->satisfied);
}
public function test_returns_payload_with_overridden_message_if_input_is_invalid(): void
{
$override = "Override message.";
$rule = $this->get_rule($override);
$is_valid = $rule->check(["input" => $this->get_invalid_input()], "input");
self::assertNotNull($is_valid);
self::assertEquals($override, $is_valid->payload["message"]);
}
public function test_returns_payload_with_key_of_invalid_input_if_input_is_invalid(): void
{
$rule = $this->get_rule();
$is_valid = $rule->check(
[
"valid1" => $this->get_valid_input(),
"invalid" => $this->get_invalid_input(),
"valid2" => $this->get_valid_input(),
],
"invalid"
);
self::assertNotNull($is_valid);
self::assertEquals("invalid", $is_valid->payload["target"]);
}
abstract public function check_provider(): array;
}

View File

@ -1,156 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\validator;
use Monolog\Test\TestCase;
/**
* Unit tests for `Validator`.
*/
class ValidatorTest extends TestCase
{
function test_validate_inputs_returns_null_if_there_are_no_rule_sets(): void
{
$inputs = ["among" => "thief", "line" => "age"];
$rule_sets = [];
$is_valid = Validator::validate_inputs($inputs, $rule_sets);
self::assertNull($is_valid);
}
function test_validate_inputs_returns_null_if_all_inputs_are_valid(): void
{
$inputs = ["among" => "thief", "line" => "age"];
$rule_sets = [
"among" => [new IsSetRule(), new IsNotBlankRule()],
"line" => [new IsSetRule()]
];
$is_valid = Validator::validate_inputs($inputs, $rule_sets);
self::assertNull($is_valid);
}
function test_validate_inputs_considers_empty_rule_set_to_always_be_valid(): void
{
$inputs = ["among" => "thief", "line" => "age"];
$rule_sets = ["among" => []];
$is_valid = Validator::validate_inputs($inputs, $rule_sets);
self::assertNull($is_valid);
}
function test_validate_inputs_returns_not_null_if_at_least_one_rule_is_violated(): void
{
$inputs = ["among" => "", "line" => "age"];
$rule_sets = [
"among" => [new IsSetRule(), new IsNotBlankRule()],
"line" => [new IsSetRule()]
];
$is_valid = Validator::validate_inputs($inputs, $rule_sets);
self::assertNotNull($is_valid);
}
function test_validate_inputs_returns_first_violation_if_multiple_inputs_are_invalid(): void
{
$inputs = [];
$rule_sets = [
"among" => [new IsSetRule()],
"line" => [new IsSetRule()]
];
$is_valid = Validator::validate_inputs($inputs, $rule_sets);
self::assertNotNull($is_valid);
self::assertEquals("among", $is_valid->payload["target"]);
}
function test_validate_inputs_returns_first_violation_if_one_input_is_invalid_in_multiple_ways(): void
{
$inputs = ["line" => "age"];
$rule_sets = [
"among" => [new IsSetRule(), new IsNotBlankRule()],
"line" => [new IsSetRule()]
];
$is_valid = Validator::validate_inputs($inputs, $rule_sets);
self::assertNotNull($is_valid);
self::assertEquals("among", $is_valid->payload["target"]);
self::assertEquals("Field 'among' required.", $is_valid->payload["message"]);
}
function test_validate_logged_in_returns_null_if_uuid_is_set(): void
{
$session = ["uuid" => "value"];
$is_valid = Validator::validate_logged_in($session);
self::assertNull($is_valid);
}
function test_validate_logged_in_returns_not_null_if_uuid_is_not_set(): void
{
$session = [];
$is_valid = Validator::validate_logged_in($session);
self::assertNotNull($is_valid);
}
function test_validate_logged_out_returns_not_null_if_uuid_is_set(): void
{
$session = ["uuid" => "value"];
$is_valid = Validator::validate_logged_out($session);
self::assertNotNull($is_valid);
}
function test_validate_logged_out_returns_null_if_uuid_is_not_set(): void
{
$session = [];
$is_valid = Validator::validate_logged_out($session);
self::assertNull($is_valid);
}
function test_validate_token_returns_null_if_token_equals_input(): void
{
$token = "meow";
$array = ["token" => $token];
$is_valid = Validator::validate_token($array, $token);
self::assertNull($is_valid);
}
function test_validate_token_returns_not_null_if_token_is_not_set(): void
{
$token = "meow";
$array = [];
$is_valid = Validator::validate_token($array, $token);
self::assertNotNull($is_valid);
}
function test_validate_token_returns_not_null_if_token_does_not_equal_input(): void
{
$token = "meow";
$array = ["token" => $token];
$is_valid = Validator::validate_token($array, "woof");
self::assertNotNull($is_valid);
}
}