Add unit tests for server-side input validation

This commit is contained in:
Florine W. Dekker 2022-11-27 11:39:40 +01:00
parent d20147101c
commit 4bca46fb97
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
13 changed files with 490 additions and 15 deletions

View File

@ -57,12 +57,16 @@ module.exports = grunt => {
},
run: {
composer_dev: {
cmd: "composer.phar",
args: ["install"]
exec: "composer.phar install"
},
composer: {
cmd: "composer.phar",
args: ["install", "--no-dev"]
exec: "composer.phar install --no-dev"
},
phpunit: {
exec: "vendor/bin/phpunit --testdox src/test"
},
stan: {
exec: "vendor/bin/phpstan analyse -l 8 src/main src/test"
},
},
watch: {
@ -151,5 +155,9 @@ module.exports = grunt => {
"replace:deploy",
]);
grunt.registerTask("analyze", ["run:stan"]);
grunt.registerTask("check", ["dev", "run:stan", "run:phpunit"]);
grunt.registerTask("test", ["dev", "run:phpunit"]);
grunt.registerTask("default", ["dev"]);
};

View File

@ -11,6 +11,7 @@ This tool regularly checks if people are still alive according to Wikipedia, and
### Requirements
* PHP 8.1+ (i.e. `apt install php php-cgi`)
* [PHP cURL](https://www.php.net/manual/en/book.curl.php) (i.e. `apt install php-curl`)
* [PHP DOM](https://www.php.net/manual/en/book.dom.php) (i.e. `apt install php-dom`)
* [PHP SQLite 3](https://www.php.net/manual/en/book.sqlite3.php) (i.e. `apt install php-sqlite3`)
* [composer](https://getcomposer.org/)
* [npm](https://www.npmjs.com/)
@ -41,8 +42,12 @@ $> npm install
### Static analysis
```shell script
# PHP static analysis
$> npm run stan
# Run static analysis
$> npm run analyze
# Run tests
$> npm run test
# Run static analysis and tests
$> npm run check
```

View File

@ -23,9 +23,14 @@
"phpmailer/phpmailer": "^6.6"
},
"require-dev": {
"phpstan/phpstan": "^1.9"
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^9.5"
},
"autoload": {
"classmap": [
"src/main/php/",
"src/test/php/"
],
"psr-4": {
"php\\": "php/"
}

BIN
composer.lock generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.14.7", "_comment_version": "Also update version in `composer.json`!",
"version": "0.14.8", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",
@ -10,11 +10,13 @@
},
"private": true,
"scripts": {
"analyze": "grunt analyze",
"check": "grunt check",
"clean": "grunt clean",
"dev": "grunt dev",
"dev:server": "grunt dev:server",
"deploy": "grunt deploy",
"stan": "vendor/bin/phpstan analyse -l 8 src/main"
"test": "grunt test"
},
"devDependencies": {
"grunt": "^1.5.3",

View File

@ -37,13 +37,12 @@ a.red-link {
/*noinspection CssUnresolvedCustomProperty*/
margin-bottom: var(--typography-spacing-vertical);
border-bottom: .1rem solid #e1e1e1;
overflow-y: scroll;
/* Shows scrolling shadows. Adapted from https://css-tricks.com/books/greatest-css-tricks/scroll-shadows/ */
/*noinspection CssUnresolvedCustomProperty*/
background: linear-gradient(white 30%, rgba(255, 255, 255, 0)) no-repeat local center calc(var(--spacing) + 1.6em),
linear-gradient(rgba(255, 255, 255, 0), white 70%) no-repeat local center bottom,
background: linear-gradient(var(--card-background-color) 30%, rgba(255, 255, 255, 0)) no-repeat local center calc(var(--spacing) + 1.6em),
linear-gradient(rgba(255, 255, 255, 0), var(--card-background-color) 70%) no-repeat local center bottom,
radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)) no-repeat scroll center calc(var(--spacing) + 1.6em),
radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)) no-repeat scroll center bottom;
background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
@ -56,7 +55,8 @@ a.red-link {
#trackings thead {
position: sticky;
top: 0;
background: white;
/*noinspection CssUnresolvedCustomProperty*/
background: var(--card-background-color);
}
#trackings td {

View File

@ -9,7 +9,8 @@
<meta name="theme-color" content="#0033cc" />
<meta name="fwd:nav:target" content="#nav" />
<meta name="fwd:nav:highlight-path" content="/Tools/Death-Notifier/" />
<!-- Remove experimental after release -->
<meta name="fwd:nav:highlight-path" content="/Tools/Experimental/Death Notifier/" />
<meta name="fwd:footer:target" content="#footer" />
<meta name="fwd:footer:vcs-url" content="https://git.fwdekker.com/tools/death-notifier/" />
<meta name="fwd:footer:version" content="v%%VERSION_NUMBER%%" />
@ -108,7 +109,6 @@
</hgroup>
</header>
<div class="article-contents">
<!-- TODO: Receive requirements from server(?) (combine this with client-side validation) -->
<form id="register-form" novalidate>
<article class="status-card hidden" data-status-for="register-form">
<output></output>

View File

@ -0,0 +1,46 @@
<?php
use php\IsEmailRule;
use php\Rule;
/**
* Unit tests for `IsEmailRule`.
*/
final class IsEmailRuleTest extends RuleTest
{
function get_rule(?string $override = null): Rule
{
return new IsEmailRule($override);
}
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");
$this->assertNull($is_valid);
}
public function test_returns_response_message_if_email_is_invalid(): void
{
$rule = new IsEmailRule();
$is_valid = $rule->check(["email" => "exampleexample.com"], "email");
$this->assertNotNull($is_valid);
$this->assertEquals("Enter a valid email address.", $is_valid->payload["message"]);
}
}

View File

@ -0,0 +1,56 @@
<?php
use php\IsNotBlankRule;
use php\Rule;
/**
* Unit tests for `IsNotBlankRule`.
*/
final 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");
$this->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");
$this->assertNotNull($is_valid);
$this->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");
$this->assertNotNull($is_valid);
$this->assertEquals("Use at least one character.", $is_valid->payload["message"]);
}
}

View File

@ -0,0 +1,26 @@
<?php
use php\IsSetRule;
use php\Rule;
/**
* Unit tests for `IsSetRule`.
*/
final 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

@ -0,0 +1,72 @@
<?php
use php\LengthRule;
use php\Rule;
/**
* Unit tests for `LengthRule`.
*/
final class LengthRuleTest extends RuleTest
{
function get_rule(?string $override = null): Rule
{
return new LengthRule(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 LengthRule(1, 3);
$is_valid = $rule->check(["input" => "a"], "input");
$this->assertNull($is_valid);
}
public function test_returns_null_if_input_is_exactly_maximum_length(): void
{
$rule = new LengthRule(1, 3);
$is_valid = $rule->check(["input" => "123"], "input");
$this->assertNull($is_valid);
}
public function test_returns_null_if_input_is_strictly_inside_range(): void
{
$rule = new LengthRule(1, 3);
$is_valid = $rule->check(["input" => "12"], "input");
$this->assertNull($is_valid);
}
public function test_returns_not_null_if_input_is_strictly_below_minimum(): void
{
$rule = new LengthRule(1, 3);
$is_valid = $rule->check(["input" => ""], "input");
$this->assertNotNull($is_valid);
}
public function test_returns_not_null_if_input_is_strictly_above_maximum(): void
{
$rule = new LengthRule(1, 3);
$is_valid = $rule->check(["input" => "1234"], "input");
$this->assertNotNull($is_valid);
}
}

97
src/test/php/RuleTest.php Normal file
View File

@ -0,0 +1,97 @@
<?php
use Monolog\Test\TestCase;
use php\Rule;
/**
* Inheritable unit tests for extensions of `Rule`.
*/
abstract class RuleTest extends TestCase
{
/**
* Constructs a new `Rule` of the class under test, using `override`.
*
* 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`
*/
abstract function get_rule(?string $override = null): Rule;
/**
* @return string|null a valid input to give to `get_rule`
*/
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");
$this->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");
$this->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");
$this->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");
$this->assertNotNull($is_valid);
$this->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");
$this->assertNotNull($is_valid);
$this->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"
);
$this->assertNotNull($is_valid);
$this->assertEquals("invalid", $is_valid->payload["target"]);
}
}

View File

@ -0,0 +1,158 @@
<?php
use Monolog\Test\TestCase;
use php\IsEmailRule;
use php\IsNotBlankRule;
use php\IsSetRule;
use php\Validator;
/**
* Unit tests for `Validator`.
*/
final 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);
$this->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);
$this->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);
$this->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);
$this->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);
$this->assertNotNull($is_valid);
$this->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);
$this->assertNotNull($is_valid);
$this->assertEquals("among", $is_valid->payload["target"]);
$this->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);
$this->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);
$this->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);
$this->assertNotNull($is_valid);
}
function test_validate_logged_out_returns_null_if_uuid_is_not_set(): void
{
$session = [];
$is_valid = Validator::validate_logged_out($session);
$this->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);
$this->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);
$this->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");
$this->assertNotNull($is_valid);
}
}