diff --git a/lib/Database.php b/lib/Database.php index d683fdad..8371a2a1 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -18,12 +18,17 @@ class Database { return (string) preg_filter("[^0-9a-zA-Z_\.]", "", $name); } - public function __construct() { - $this->driver = $driver = Data::$conf->dbDriver; - $this->db = new $driver(INSTALL); - $ver = $this->db->schemaVersion(); - if(!INSTALL && $ver < self::SCHEMA_VERSION) { - $this->db->schemaUpdate(self::SCHEMA_VERSION); + public function __construct(Db\Driver $db = null) { + // if we're fed a pre-prepared driver, use it' + if($db) { + $this->db = $db; + } else { + $this->driver = $driver = Data::$conf->dbDriver; + $this->db = new $driver(INSTALL); + $ver = $this->db->schemaVersion(); + if(!INSTALL && $ver < self::SCHEMA_VERSION) { + $this->db->schemaUpdate(self::SCHEMA_VERSION); + } } } @@ -186,15 +191,21 @@ class Database { } public function userList(string $domain = null): array { + $out = []; if($domain !== null) { if(!Data::$user->authorize("@".$domain, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $domain]); $domain = str_replace(["\\","%","_"],["\\\\", "\\%", "\\_"], $domain); $domain = "%@".$domain; - return $this->db->prepare("SELECT id from arsse_users where id like ?", "str")->run($domain)->getAll(); + foreach($this->db->prepare("SELECT id from arsse_users where id like ?", "str")->run($domain) as $user) { + $out[] = $user['id']; + } } else { if(!Data::$user->authorize("", __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => "global"]); - return $this->db->prepare("SELECT id from arsse_users")->run()->getAll(); + foreach($this->db->prepare("SELECT id from arsse_users")->run() as $user) { + $out[] = $user['id']; + } } + return $out; } public function userPasswordGet(string $user): string { @@ -208,7 +219,7 @@ class Database { if(!$this->userExists($user)) throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); if($password===null) $password = (new PassGen)->length(Data::$conf->userTempPasswordLength)->get(); $hash = ""; - if(strlen($password > 0)) $hash = password_hash($password, \PASSWORD_DEFAULT); + if(strlen($password) > 0) $hash = password_hash($password, \PASSWORD_DEFAULT); $this->db->prepare("UPDATE arsse_users set password = ? where id is ?", "str", "str")->run($hash, $user); return $password; } @@ -216,16 +227,16 @@ class Database { public function userPropertiesGet(string $user): array { if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); $prop = $this->db->prepare("SELECT name,rights from arsse_users where id is ?", "str")->run($user)->getRow(); - if(!$prop) return []; + if(!$prop) throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); return $prop; } - public function userPropertiesSet(string $user, array &$properties): array { + public function userPropertiesSet(string $user, array $properties): array { if(!Data::$user->authorize($user, __FUNCTION__)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); $valid = [ // FIXME: add future properties "name" => "str", ]; - if(!$this->userExists($user)) return []; + if(!$this->userExists($user)) throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); $this->db->begin(); foreach($valid as $prop => $type) { if(!array_key_exists($prop, $properties)) continue; @@ -242,7 +253,7 @@ class Database { public function userRightsSet(string $user, int $rights): bool { if(!Data::$user->authorize($user, __FUNCTION__, $rights)) throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]); - if(!$this->userExists($user)) return false; + if(!$this->userExists($user)) throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); $this->db->prepare("UPDATE arsse_users set rights = ? where id is ?", "int", "str")->run($rights, $user); return true; } diff --git a/tests/Db/SQLite3/Database/TestDatabaseUser.php b/tests/Db/SQLite3/Database/TestDatabaseUser.php new file mode 100644 index 00000000..3abaa9b9 --- /dev/null +++ b/tests/Db/SQLite3/Database/TestDatabaseUser.php @@ -0,0 +1,289 @@ + [ + 'columns' => [ + 'id' => 'str', + 'password' => 'str', + 'name' => 'str', + 'rights' => 'int', + ], + 'rows' => [ + ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', "Hard Lip Herbert", User\Driver::RIGHTS_GLOBAL_ADMIN], // password is hash of "secret" + ["jane.doe@example.com", "", "Jane Doe", User\Driver::RIGHTS_NONE], + ["john.doe@example.com", "", "John Doe", User\Driver::RIGHTS_NONE], + ], + ], + ]; + + function setUp() { + // establish a clean baseline + $this->clearData(); + // create a default configuration + Data::$conf = new Conf(); + // configure and create the relevant database driver + Data::$conf->dbSQLite3File = ":memory:"; + $this->drv = new Db\SQLite3\Driver(true); + // create the database interface with the suitable driver + Data::$db = new Database($this->drv); + Data::$db->schemaUpdate(); + // create a mock user manager + Data::$user = Phake::mock(User::class); + Phake::when(Data::$user)->authorize->thenReturn(true); + // call the additional setup method if it exists + if(method_exists($this, "setUpSeries")) $this->setUpSeries(); + } + + function tearDown() { + // call the additional teardiwn method if it exists + if(method_exists($this, "tearDownSeries")) $this->tearDownSeries(); + // clean up + $this->drv = null; + $this->clearData(); + } + + function setUpSeries() { + $this->primeDatabase($this->data); + } + + function testCheckThatAUserExists() { + $this->assertTrue(Data::$db->userExists("jane.doe@example.com")); + $this->assertFalse(Data::$db->userExists("jane.doe@example.org")); + Phake::verify(Data::$user)->authorize("jane.doe@example.com", "userExists"); + Phake::verify(Data::$user)->authorize("jane.doe@example.org", "userExists"); + $this->compareExpectations($this->data); + } + + function testCheckThatAUserExistsWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userExists("jane.doe@example.com"); + } + + function testGetAPassword() { + $hash = Data::$db->userPasswordGet("admin@example.net"); + $this->assertSame('$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', $hash); + Phake::verify(Data::$user)->authorize("admin@example.net", "userPasswordGet"); + $this->assertTrue(password_verify("secret", $hash)); + } + + function testGetAPasswordWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userPasswordGet("admin@example.net"); + } + + function testAddANewUser() { + $this->assertSame("", Data::$db->userAdd("john.doe@example.org", "")); + Phake::verify(Data::$user)->authorize("john.doe@example.org", "userAdd"); + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','name','rights']]); + $state['arsse_users']['rows'][] = ["john.doe@example.org", null, User\Driver::RIGHTS_NONE]; + $this->compareExpectations($state); + } + + /** + * @depends testGetAPassword + * @depends testAddANewUser + */ + function testAddANewUserWithARandomPassword() { + $user1 = "john.doe@example.org"; + $user2 = "john.doe@example.net"; + $pass1 = Data::$db->userAdd($user1); + $pass2 = Data::$db->userAdd($user2); + $this->assertSame(Data::$conf->userTempPasswordLength, strlen($pass1)); + $this->assertSame(Data::$conf->userTempPasswordLength, strlen($pass2)); + $this->assertNotEquals($pass1, $pass2); + $hash1 = Data::$db->userPasswordGet($user1); + $hash2 = Data::$db->userPasswordGet($user2); + Phake::verify(Data::$user)->authorize($user1, "userAdd"); + Phake::verify(Data::$user)->authorize($user2, "userAdd"); + Phake::verify(Data::$user)->authorize($user1, "userPasswordGet"); + Phake::verify(Data::$user)->authorize($user2, "userPasswordGet"); + $this->assertTrue(password_verify($pass1, $hash1), "Failed verifying password of $user1 '$pass1' against hash '$hash1'."); + $this->assertTrue(password_verify($pass2, $hash2), "Failed verifying password of $user2 '$pass2' against hash '$hash2'."); + } + + function testAddAnExistingUser() { + $this->assertException("alreadyExists", "User"); + Data::$db->userAdd("john.doe@example.com", ""); + } + + function testAddANewUserWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userAdd("john.doe@example.org", ""); + } + + function testRemoveAUser() { + $this->assertTrue(Data::$db->userRemove("admin@example.net")); + Phake::verify(Data::$user)->authorize("admin@example.net", "userRemove"); + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]); + array_shift($state['arsse_users']['rows']); + $this->compareExpectations($state); + } + + function testRemoveAMissingUser() { + $this->assertException("doesNotExist", "User"); + Data::$db->userRemove("john.doe@example.org"); + } + + function testRemoveAUserWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userRemove("admin@example.net"); + } + + function testListAllUsers() { + $users = ["admin@example.net", "jane.doe@example.com", "john.doe@example.com"]; + $this->assertSame($users, Data::$db->userList()); + Phake::verify(Data::$user)->authorize("", "userList"); + } + + function testListUsersOnADomain() { + $users = ["jane.doe@example.com", "john.doe@example.com"]; + $this->assertSame($users, Data::$db->userList("example.com")); + Phake::verify(Data::$user)->authorize("@example.com", "userList"); + } + + function testListAllUsersWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userList(); + } + + function testListUsersOnADomainWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userList("example.com"); + } + + /** + * @depends testGetAPassword + */ + function testSetAPassword() { + $user = "john.doe@example.com"; + $this->assertEquals("", Data::$db->userPasswordGet($user)); + $pass = Data::$db->userPasswordSet($user, "secret"); + $hash = Data::$db->userPasswordGet($user); + $this->assertNotEquals("", $hash); + Phake::verify(Data::$user)->authorize($user, "userPasswordSet"); + $this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'."); + } + + function testSetThePasswordOfAMissingUser() { + $this->assertException("doesNotExist", "User"); + Data::$db->userPasswordSet("john.doe@example.org", "secret"); + } + + function testSetAPasswordWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userPasswordSet("john.doe@example.com", "secret"); + } + + function testGetUserProperties() { + $exp = [ + 'name' => 'Hard Lip Herbert', + 'rights' => User\Driver::RIGHTS_GLOBAL_ADMIN, + ]; + $props = Data::$db->userPropertiesGet("admin@example.net"); + Phake::verify(Data::$user)->authorize("admin@example.net", "userPropertiesGet"); + $this->assertArraySubset($exp, $props); + $this->assertArrayNotHasKey("password", $props); + } + + function testGetThePropertiesOfAMissingUser() { + $this->assertException("doesNotExist", "User"); + Data::$db->userPropertiesGet("john.doe@example.org"); + } + + function testGetUserPropertiesWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userPropertiesGet("john.doe@example.com"); + } + + function testSetUserProperties() { + $try = [ + 'name' => 'James Kirk', // only this should actually change + 'password' => '000destruct0', + 'rights' => User\Driver::RIGHTS_NONE, + 'lifeform' => 'tribble', + ]; + $exp = [ + 'name' => 'James Kirk', + 'rights' => User\Driver::RIGHTS_GLOBAL_ADMIN, + ]; + $props = Data::$db->userPropertiesSet("admin@example.net", $try); + Phake::verify(Data::$user)->authorize("admin@example.net", "userPropertiesSet"); + $this->assertArraySubset($exp, $props); + $this->assertArrayNotHasKey("password", $props); + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','password','name','rights']]); + $state['arsse_users']['rows'][0][2] = "James Kirk"; + $this->compareExpectations($state); + } + + function testSetThePropertiesOfAMissingUser() { + $try = ['name' => 'John Doe']; + $this->assertException("doesNotExist", "User"); + Data::$db->userPropertiesSet("john.doe@example.org", $try); + } + + function testSetUserPropertiesWithoutAuthority() { + $try = ['name' => 'John Doe']; + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userPropertiesSet("john.doe@example.com", $try); + } + + function testGetUserRights() { + $user1 = "john.doe@example.com"; + $user2 = "admin@example.net"; + $this->assertSame(User\Driver::RIGHTS_NONE, Data::$db->userRightsGet($user1)); + $this->assertSame(User\Driver::RIGHTS_GLOBAL_ADMIN, Data::$db->userRightsGet($user2)); + Phake::verify(Data::$user)->authorize($user1, "userRightsGet"); + Phake::verify(Data::$user)->authorize($user2, "userRightsGet"); + } + + function testGetTheRightsOfAMissingUser() { + $this->assertSame(User\Driver::RIGHTS_NONE, Data::$db->userRightsGet("john.doe@example.org")); + Phake::verify(Data::$user)->authorize("john.doe@example.org", "userRightsGet"); + } + + function testGetUserRightsWithoutAuthority() { + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userRightsGet("john.doe@example.com"); + } + + function testSetUserRights() { + $user = "john.doe@example.com"; + $rights = User\Driver::RIGHTS_GLOBAL_ADMIN; + $this->assertTrue(Data::$db->userRightsSet($user, $rights)); + Phake::verify(Data::$user)->authorize($user, "userRightsSet", $rights); + $state = $this->primeExpectations($this->data, ['arsse_users' => ['id','rights']]); + $state['arsse_users']['rows'][2][1] = $rights; + $this->compareExpectations($state); + } + + function testSetTheRightsOfAMissingUser() { + $rights = User\Driver::RIGHTS_GLOBAL_ADMIN; + $this->assertException("doesNotExist", "User"); + Data::$db->userRightsSet("john.doe@example.org", $rights); + } + + function testSetUserRightsWithoutAuthority() { + $rights = User\Driver::RIGHTS_GLOBAL_ADMIN; + Phake::when(Data::$user)->authorize->thenReturn(false); + $this->assertException("notAuthorized", "User", "ExceptionAuthz"); + Data::$db->userRightsSet("john.doe@example.com", $rights); + } +} \ No newline at end of file diff --git a/tests/Db/TestDatabase.php b/tests/Db/TestDatabase.php deleted file mode 100644 index 078630da..00000000 --- a/tests/Db/TestDatabase.php +++ /dev/null @@ -1,10 +0,0 @@ -begin(); + function primeDatabase(array $data): bool { + $this->drv->begin(); foreach($data as $table => $info) { $cols = implode(",", array_keys($info['columns'])); $bindings = array_values($info['columns']); @@ -17,21 +17,50 @@ trait Tools { $this->assertEquals(1, $s->runArray($row)->changes()); } } - $drv->commit(); + $this->drv->commit(); return true; } - function compare(array $expected): bool { + function compareExpectations(array $expected): bool { foreach($expected as $table => $info) { $cols = implode(",", array_keys($info['columns'])); foreach($this->drv->prepare("SELECT $cols from $table")->run() as $num => $row) { $row = array_values($row); - $assertSame($expected[$table]['rows'][$num], $row, "Row $num of table $table does not match expectation."); + $this->assertSame($expected[$table]['rows'][$num], $row, "Row ".($num+1)." of table $table does not match expectations at array index $num."); } } + return true; } - function setUp() { - + function primeExpectations(array $source, array $tableSpecs = null): array { + $out = []; + foreach($tableSpecs as $table => $columns) { + if(!isset($source[$table])) { + $this->assertTrue(false, "Source for expectations does not contain requested table $table."); + return []; + } + $out[$table] = [ + 'columns' => [], + 'rows' => [], + ]; + $transformations = []; + foreach($columns as $target => $col) { + if(!isset($source[$table]['columns'][$col])) { + $this->assertTrue(false, "Source for expectations does not contain requested column $col of table $table."); + return []; + } + $found = array_search($col, array_keys($source[$table]['columns'])); + $transformations[$found] = $target; + $out[$table]['columns'][$col] = $source[$table]['columns'][$col]; + } + foreach($source[$table]['rows'] as $sourceRow) { + $newRow = []; + foreach($transformations as $from => $to) { + $newRow[$to] = $sourceRow[$from]; + } + $out[$table]['rows'][] = $newRow; + } + } + return $out; } } \ No newline at end of file diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 2331c18f..103fbd0e 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -38,8 +38,8 @@ Db/SQLite3/TestDbUpdateSQLite3.php - - Db/TestDatabase.php + + Db/SQLite3/Database/TestDatabaseUser.php