diff --git a/lib/AbstractException.php b/lib/AbstractException.php index a524da60..e4f22a9a 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -86,6 +86,8 @@ abstract class AbstractException extends \Exception { "Feed/Exception.xmlEntity" => 10512, "Feed/Exception.subscriptionNotFound" => 10521, "Feed/Exception.unsupportedFeedFormat" => 10522, + "ImportExport/Exception.fileUnwritable" => 10604, + "ImportExport/Exception.fileUncreatable" => 10605, ]; public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) { diff --git a/lib/ImportExport/Exception.php b/lib/ImportExport/Exception.php new file mode 100644 index 00000000..888cfcac --- /dev/null +++ b/lib/ImportExport/Exception.php @@ -0,0 +1,10 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } $tags = []; $folders = []; $parents = [0 => null]; @@ -68,4 +72,14 @@ class OPML { // return the serialization return $document->saveXML(); } + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); + } + return true; + } } diff --git a/locale/en.php b/locale/en.php index ddbf1182..a9fa045f 100644 --- a/locale/en.php +++ b/locale/en.php @@ -155,4 +155,14 @@ return [ 'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack', 'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"', 'Exception.JKingWeb/Arsse/Feed/Exception.unsupportedFeedFormat' => 'Feed "{url}" is of an unsupported format', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUncreatable' => + 'Insufficient permissions to write {type, select, + OPML {OPML} + other {"{type}"} + } export to file "{file}"', + 'Exception.JKingWeb/Arsse/ImportExport/Exception.fileUnwritable' => + 'Insufficient permissions to write {type, select, + OPML {OPML} + other {"{type}"} + } export to existing file "{file}"', ]; diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php index 387400c2..2c8d7d29 100644 --- a/tests/cases/ImportExport/TestOPML.php +++ b/tests/cases/ImportExport/TestOPML.php @@ -79,7 +79,10 @@ OPML_EXPORT_SERIALIZATION; OPML_EXPORT_SERIALIZATION; public function setUp() { + self::clearData(); Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class); + Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class); + \Phake::when(Arsse::$user)->exists->thenReturn(true); } public function testExportToOpml() { @@ -95,4 +98,10 @@ OPML_EXPORT_SERIALIZATION; \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags)); $this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true)); } + + public function testExportToOpmlAMissingUser() { + \Phake::when(Arsse::$user)->exists->thenReturn(false); + $this->assertException("doesNotExist", "User"); + (new OPML)->export("john.doe@example.com"); + } } diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestOPMLFile.php new file mode 100644 index 00000000..ecb601d1 --- /dev/null +++ b/tests/cases/ImportExport/TestOPMLFile.php @@ -0,0 +1,82 @@ + */ +class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { + protected $vfs; + protected $path; + protected $opml; + + public function setUp() { + self::clearData(); + // create a mock OPML processor with stubbed underlying import/export routines + $this->opml = \Phake::partialMock(OPML::class); + \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); + $this->vfs = vfsStream::setup("root", null, [ + 'exportGoodFile' => "", + 'exportGoodDir' => [], + 'exportBadFile' => "", + 'exportBadDir' => [], + ]); + $this->path = $this->vfs->url()."/"; + // make the "bad" entries inaccessible + chmod($this->path."exportBadFile", 0000); + chmod($this->path."exportBadDir", 0000); + } + + public function tearDown() { + $this->path = null; + $this->vfs = null; + $this->opml = null; + self::clearData(); + } + + /** @dataProvider provideFileExports */ + public function testExportOpmlToAFile(string $file, string $user, bool $flat, $exp) { + $path = $this->path.$file; + try { + if ($exp instanceof \JKingWeb\Arsse\AbstractException) { + $this->assertException($exp); + $this->opml->exportFile($path, $user, $flat); + } else { + $this->assertSame($exp, $this->opml->exportFile($path, $user, $flat)); + $this->assertSame("OPML_FILE", $this->vfs->getChild($file)->getContent()); + } + } finally { + \Phake::verify($this->opml)->export($user, $flat); + } + } + + public function provideFileExports() { + $createException = new Exception("fileUncreatable"); + $writeException = new Exception("fileUnwritable"); + return [ + ["exportGoodFile", "john.doe@example.com", true, true], + ["exportGoodFile", "john.doe@example.com", false, true], + ["exportGoodFile", "jane.doe@example.com", true, true], + ["exportGoodFile", "jane.doe@example.com", false, true], + ["exportGoodDir/file", "john.doe@example.com", true, true], + ["exportGoodDir/file", "john.doe@example.com", false, true], + ["exportGoodDir/file", "jane.doe@example.com", true, true], + ["exportGoodDir/file", "jane.doe@example.com", false, true], + ["exportBadFile", "john.doe@example.com", true, $writeException], + ["exportBadFile", "john.doe@example.com", false, $writeException], + ["exportBadFile", "jane.doe@example.com", true, $writeException], + ["exportBadFile", "jane.doe@example.com", false, $writeException], + ["exportBadDir/file", "john.doe@example.com", true, $createException], + ["exportBadDir/file", "john.doe@example.com", false, $createException], + ["exportBadDir/file", "jane.doe@example.com", true, $createException], + ["exportBadDir/file", "jane.doe@example.com", false, $createException], + ]; + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index fd5429fa..6ad94f32 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -115,6 +115,7 @@ cases/ImportExport/TestOPML.php + cases/ImportExport/TestOPMLFile.php