Better privilege checking for batch operations

This commit is contained in:
Marcin Kurczewski 2014-05-06 19:39:41 +02:00
parent cd437ca036
commit 410237d678
30 changed files with 314 additions and 85 deletions

View file

@ -73,7 +73,6 @@ passwordResetEmailBody = "Hello,{nl}{nl}You received this e-mail because someone
registerAccount=anonymous
;registerAccount=nobody
uploadPost=registered
listPosts=anonymous
listPosts.sketchy=registered
listPosts.unsafe=registered
@ -84,6 +83,16 @@ viewPost.unsafe=registered
viewPost.hidden=moderator
retrievePost=anonymous
favoritePost=registered
addPost=registered
addPostSafety=registered
addPostTags=registered
addPostThumb=power-user
addPostSource=registered
addPostRelations=power-user
addPostContent=registered
editPost=registered
editPostSafety.own=registered
editPostSafety.all=moderator
editPostTags=registered
@ -92,6 +101,7 @@ editPostSource=moderator
editPostRelations.own=registered
editPostRelations.all=moderator
editPostContent=moderator
massTag.own=registered
massTag.all=power-user
hidePost=moderator

View file

@ -86,7 +86,7 @@ class Access
public static function assert(Privilege $privilege, $user = null)
{
if (!self::check($privilege, $user))
self::fail();
self::fail('Insufficient privileges (' . $privilege->toString() . ')');
}
public static function assertEmailConfirmation($user = null)
@ -95,9 +95,9 @@ class Access
self::fail('Need e-mail address confirmation to continue');
}
public static function fail($message = 'Insufficient privileges')
public static function fail($message)
{
throw new SimpleException($message);
throw new AccessException($message);
}
public static function getIdentity($user)

4
src/AccessException.php Normal file
View file

@ -0,0 +1,4 @@
<?php
class AccessException extends SimpleException
{
}

View file

@ -1,6 +1,10 @@
<?php
abstract class AbstractJob
{
const CONTEXT_NORMAL = 1;
const CONTEXT_BATCH_EDIT = 2;
const CONTEXT_BATCH_ADD = 3;
const COMMENT_ID = 'comment-id';
const LOG_ID = 'log-id';
@ -21,6 +25,7 @@ abstract class AbstractJob
const STATE = 'state';
protected $arguments = [];
protected $context = self::CONTEXT_NORMAL;
public function prepare()
{
@ -28,6 +33,11 @@ abstract class AbstractJob
public abstract function execute();
public function isSatisfied()
{
return true;
}
public function requiresAuthentication()
{
return false;
@ -43,6 +53,16 @@ abstract class AbstractJob
return false;
}
public function getContext()
{
return $this->context;
}
public function setContext($context)
{
$this->context = $context;
}
public function getArgument($key)
{
if (!$this->hasArgument($key))

View file

@ -8,6 +8,10 @@ final class Api
return \Chibi\Database::transaction(function() use ($job, $jobArgs)
{
$job->setArguments($jobArgs);
if (!$job->isSatisfied())
throw new ApiJobUnsatisfiedException($job);
$job->prepare();
self::checkPrivileges($job);

View file

@ -0,0 +1,8 @@
<?php
class ApiJobUnsatisfiedException extends SimpleException
{
public function __construct(AbstractJob $job)
{
parent::__construct(get_class($job) . ' cannot be run due to unsatisfied execution conditions.');
}
}

View file

@ -1,11 +0,0 @@
<?php
abstract class AbstractPostEditJob extends AbstractPostJob
{
protected $skipSaving = false;
public function skipSaving()
{
$this->skipSaving = true;
return $this;
}
}

View file

@ -1,11 +0,0 @@
<?php
abstract class AbstractUserEditJob extends AbstractUserJob
{
protected $skipSaving = false;
public function skipSaving()
{
$this->skipSaving = true;
return $this;
}
}

View file

@ -6,7 +6,6 @@ class AddPostJob extends AbstractJob
public function execute()
{
$post = PostModel::spawn();
Logger::bufferChanges();
//basic stuff
$anonymous = $this->getArgument(self::ANONYMOUS);
@ -20,14 +19,16 @@ class AddPostJob extends AbstractJob
//warning: it uses internally the same privileges as post editing
$arguments = $this->getArguments();
$arguments[EditPostJob::POST_ENTITY] = $post;
Api::run((new EditPostJob)->skipSaving(), $arguments);
Logger::bufferChanges();
$job = new EditPostJob();
$job->setContext(AbstractJob::CONTEXT_BATCH_ADD);
Api::run($job, $arguments);
Logger::setBuffer([]);
//save to db
PostModel::save($post);
//clean edit log
Logger::setBuffer([]);
//log
Logger::log('{user} added {post} (tags: {tags}, safety: {safety}, source: {source})', [
'user' => ($anonymous and !getConfig()->misc->logAnonymousUploads)
@ -46,7 +47,7 @@ class AddPostJob extends AbstractJob
public function requiresPrivilege()
{
return new Privilege(Privilege::UploadPost);
return new Privilege(Privilege::AddPost);
}
public function requiresConfirmedEmail()

View file

@ -20,7 +20,9 @@ class AddUserJob extends AbstractJob
Logger::bufferChanges();
Access::disablePrivilegeChecking();
Api::run((new EditUserJob)->skipSaving(), $arguments);
$job = new EditUserJob();
$job->setContext(self::CONTEXT_BATCH_ADD);
Api::run($job, $arguments);
Access::enablePrivilegeChecking();
Logger::setBuffer([]);

View file

@ -1,9 +1,15 @@
<?php
class EditPostContentJob extends AbstractPostEditJob
class EditPostContentJob extends AbstractPostJob
{
const POST_CONTENT = 'post-content';
const POST_CONTENT_URL = 'post-content-url';
public function isSatisfied()
{
return $this->hasArgument(self::POST_CONTENT)
or $this->hasArgument(self::POST_CONTENT_URL);
}
public function execute()
{
$post = $this->post;
@ -19,7 +25,7 @@ class EditPostContentJob extends AbstractPostEditJob
$post->setContentFromPath($file->filePath, $file->fileName);
}
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
PostModel::save($post);
Logger::log('{user} changed contents of {post}', [
@ -32,7 +38,9 @@ class EditPostContentJob extends AbstractPostEditJob
public function requiresPrivilege()
{
return new Privilege(
Privilege::EditPostContent,
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPostContent
: Privilege::EditPostContent,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,5 +1,5 @@
<?php
class EditPostJob extends AbstractPostEditJob
class EditPostJob extends AbstractPostJob
{
public function execute()
{
@ -19,8 +19,9 @@ class EditPostJob extends AbstractPostEditJob
foreach ($subJobs as $subJob)
{
if ($this->skipSaving)
$subJob->skipSaving();
$subJob->setContext($this->getContext() == self::CONTEXT_BATCH_ADD
? self::CONTEXT_BATCH_ADD
: self::CONTEXT_BATCH_EDIT);
$args = $this->getArguments();
$args[self::POST_ENTITY] = $post;
@ -28,15 +29,24 @@ class EditPostJob extends AbstractPostEditJob
{
Api::run($subJob, $args);
}
catch (ApiMissingArgumentException $e)
catch (ApiJobUnsatisfiedException $e)
{
}
}
if (!$this->skipSaving)
if ($this->getContext() == AbstractJob::CONTEXT_NORMAL)
PostModel::save($post);
Logger::flush();
return $post;
}
public function requiresPrivilege()
{
return new Privilege(
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPost
: Privilege::EditPost,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,8 +1,13 @@
<?php
class EditPostRelationsJob extends AbstractPostEditJob
class EditPostRelationsJob extends AbstractPostJob
{
const RELATED_POST_IDS = 'related-post-ids';
public function isSatisfied()
{
return $this->hasArgument(self::RELATED_POST_IDS);
}
public function execute()
{
$post = $this->post;
@ -12,7 +17,7 @@ class EditPostRelationsJob extends AbstractPostEditJob
$post->setRelationsFromText($relations);
$newRelatedIds = array_map(function($post) { return $post->getId(); }, $post->getRelations());
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
PostModel::save($post);
foreach (array_diff($oldRelatedIds, $newRelatedIds) as $post2id)
@ -37,7 +42,9 @@ class EditPostRelationsJob extends AbstractPostEditJob
public function requiresPrivilege()
{
return new Privilege(
Privilege::EditPostRelations,
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPostRelations
: Privilege::EditPostRelations,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,8 +1,13 @@
<?php
class EditPostSafetyJob extends AbstractPostEditJob
class EditPostSafetyJob extends AbstractPostJob
{
const SAFETY = 'safety';
public function isSatisfied()
{
return $this->hasArgument(self::SAFETY);
}
public function execute()
{
$post = $this->post;
@ -11,7 +16,7 @@ class EditPostSafetyJob extends AbstractPostEditJob
$oldSafety = $post->getSafety();
$post->setSafety($newSafety);
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
PostModel::save($post);
if ($oldSafety != $newSafety)
@ -28,7 +33,9 @@ class EditPostSafetyJob extends AbstractPostEditJob
public function requiresPrivilege()
{
return new Privilege(
Privilege::EditPostSafety,
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPostSafety
: Privilege::EditPostSafety,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,8 +1,13 @@
<?php
class EditPostSourceJob extends AbstractPostEditJob
class EditPostSourceJob extends AbstractPostJob
{
const SOURCE = 'source';
public function isSatisfied()
{
return $this->hasArgument(self::SOURCE);
}
public function execute()
{
$post = $this->post;
@ -11,7 +16,7 @@ class EditPostSourceJob extends AbstractPostEditJob
$oldSource = $post->source;
$post->setSource($newSource);
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
PostModel::save($post);
if ($oldSource != $newSource)
@ -28,7 +33,9 @@ class EditPostSourceJob extends AbstractPostEditJob
public function requiresPrivilege()
{
return new Privilege(
Privilege::EditPostSource,
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPostSource
: Privilege::EditPostSource,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,6 +1,11 @@
<?php
class EditPostTagsJob extends AbstractPostEditJob
class EditPostTagsJob extends AbstractPostJob
{
public function isSatisfied()
{
return $this->hasArgument(self::TAG_NAMES);
}
public function execute()
{
$post = $this->post;
@ -10,7 +15,7 @@ class EditPostTagsJob extends AbstractPostEditJob
$post->setTagsFromText($tags);
$newTags = array_map(function($tag) { return $tag->getName(); }, $post->getTags());
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
{
PostModel::save($post);
TagModel::removeUnused();
@ -38,7 +43,9 @@ class EditPostTagsJob extends AbstractPostEditJob
public function requiresPrivilege()
{
return new Privilege(
Privilege::EditPostTags,
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPostTags
: Privilege::EditPostTags,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,8 +1,13 @@
<?php
class EditPostThumbJob extends AbstractPostEditJob
class EditPostThumbJob extends AbstractPostJob
{
const THUMB_CONTENT = 'thumb-content';
public function isSatisfied()
{
return $this->hasArgument(self::THUMB_CONTENT);
}
public function execute()
{
$post = $this->post;
@ -10,7 +15,7 @@ class EditPostThumbJob extends AbstractPostEditJob
$post->setCustomThumbnailFromPath($file->filePath);
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
PostModel::save($post);
Logger::log('{user} changed thumb of {post}', [
@ -23,7 +28,9 @@ class EditPostThumbJob extends AbstractPostEditJob
public function requiresPrivilege()
{
return new Privilege(
Privilege::EditPostThumb,
$this->getContext() == self::CONTEXT_BATCH_ADD
? Privilege::AddPostThumb
: Privilege::EditPostThumb,
Access::getIdentity($this->post->getUploader()));
}
}

View file

@ -1,8 +1,13 @@
<?php
class EditUserAccessRankJob extends AbstractUserEditJob
class EditUserAccessRankJob extends AbstractUserJob
{
const NEW_ACCESS_RANK = 'new-access-rank';
public function isSatisfied()
{
return $this->hasArgument(self::NEW_ACCESS_RANK);
}
public function execute()
{
$user = $this->user;
@ -14,7 +19,7 @@ class EditUserAccessRankJob extends AbstractUserEditJob
$user->setAccessRank($newAccessRank);
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
UserModel::save($user);
Logger::log('{user} changed {subject}\'s access rank to {rank}', [

View file

@ -1,8 +1,13 @@
<?php
class EditUserEmailJob extends AbstractUserEditJob
class EditUserEmailJob extends AbstractUserJob
{
const NEW_EMAIL = 'new-email';
public function isSatisfied()
{
return $this->hasArgument(self::NEW_EMAIL);
}
public function execute()
{
if (getConfig()->registration->needEmailForRegistering)
@ -29,7 +34,7 @@ class EditUserEmailJob extends AbstractUserEditJob
$user->confirmEmail();
}
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
UserModel::save($user);
Logger::log('{user} changed {subject}\'s e-mail to {mail}', [

View file

@ -1,5 +1,5 @@
<?php
class EditUserJob extends AbstractUserEditJob
class EditUserJob extends AbstractUserJob
{
protected $subJobs;
@ -25,7 +25,7 @@ class EditUserJob extends AbstractUserEditJob
Api::checkPrivileges($subJob);
return true;
}
catch (SimpleException $e)
catch (AccessException $e)
{
}
}
@ -40,8 +40,9 @@ class EditUserJob extends AbstractUserEditJob
foreach ($this->subJobs as $subJob)
{
if ($this->skipSaving)
$subJob->skipSaving();
$subJob->setContext($this->getContext() == self::CONTEXT_BATCH_ADD
? self::CONTEXT_BATCH_ADD
: self::CONTEXT_BATCH_EDIT);
$args = $this->getArguments();
$args[self::USER_ENTITY] = $user;
@ -49,12 +50,12 @@ class EditUserJob extends AbstractUserEditJob
{
Api::run($subJob, $args);
}
catch (ApiMissingArgumentException $e)
catch (ApiJobUnsatisfiedException $e)
{
}
}
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
UserModel::save($user);
Logger::flush();

View file

@ -1,8 +1,13 @@
<?php
class EditUserNameJob extends AbstractUserEditJob
class EditUserNameJob extends AbstractUserJob
{
const NEW_USER_NAME = 'new-user-name';
public function isSatisfied()
{
return $this->hasArgument(self::NEW_USER_NAME);
}
public function execute()
{
$user = $this->user;
@ -15,7 +20,7 @@ class EditUserNameJob extends AbstractUserEditJob
$user->setName($newName);
UserModel::validateUserName($user);
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
UserModel::save($user);
Logger::log('{user} renamed {old} to {new}', [

View file

@ -1,8 +1,13 @@
<?php
class EditUserPasswordJob extends AbstractUserEditJob
class EditUserPasswordJob extends AbstractUserJob
{
const NEW_PASSWORD = 'new-password';
public function isSatisfied()
{
return $this->hasArgument(self::NEW_PASSWORD);
}
public function execute()
{
$user = $this->user;
@ -15,7 +20,7 @@ class EditUserPasswordJob extends AbstractUserEditJob
$user->passHash = $newPasswordHash;
if (!$this->skipSaving)
if ($this->getContext() == self::CONTEXT_NORMAL)
UserModel::save($user);
Logger::log('{user} changed {subject}\'s password', [

View file

@ -13,7 +13,7 @@ abstract class Enum
public function toDisplayString()
{
return TextCaseConverter::convert($this->toString(),
TextCaseConverter::SNAKE_CASE,
TextCaseConverter::CAMEL_CASE,
TextCaseConverter::BLANK_CASE);
}

View file

@ -2,21 +2,30 @@
class Privilege extends Enum
{
const ListPosts = 1;
const UploadPost = 2;
const ViewPost = 3;
const RetrievePost = 4;
const FavoritePost = 5;
const HidePost = 9;
const DeletePost = 10;
const FeaturePost = 25;
const ScorePost = 31;
const FlagPost = 34;
const EditPost = 45;
const EditPostSafety = 6;
const EditPostTags = 7;
const EditPostThumb = 8;
const EditPostSource = 26;
const EditPostRelations = 30;
const EditPostContent = 36;
const HidePost = 9;
const DeletePost = 10;
const FeaturePost = 25;
const ScorePost = 31;
const FlagPost = 34;
const AddPost = 2;
const AddPostSafety = 39;
const AddPostTags = 40;
const AddPostThumb = 41;
const AddPostSource = 42;
const AddPostRelations = 43;
const AddPostContent = 44;
const RegisterAccount = 38;
const ListUsers = 11;

View file

@ -26,7 +26,7 @@
$activeController == 'post' and $activeAction != 'upload');
}
if (Access::check(new Privilege(Privilege::UploadPost)))
if (Access::check(new Privilege(Privilege::AddPost)))
{
$registerNavItem(
'Upload',

View file

@ -59,4 +59,9 @@ class AbstractTest
getConfig()->privileges->$privilege = 'nobody';
Access::init();
}
protected function getPath($name)
{
return getConfig()->rootDir . DS . 'tests' . DS . 'TestFiles' . DS . $name;
}
}

View file

@ -15,9 +15,8 @@ class ApiPrivilegeTest extends AbstractFullApiTest
$this->testRegularPrivilege(new ActivateUserEmailJob(), false);
$this->testRegularPrivilege(new AddCommentJob(), new Privilege(Privilege::AddComment));
$this->testRegularPrivilege(new PreviewCommentJob(), new Privilege(Privilege::AddComment));
$this->testRegularPrivilege(new AddPostJob(), new Privilege(Privilege::UploadPost));
$this->testRegularPrivilege(new AddPostJob(), new Privilege(Privilege::AddPost));
$this->testRegularPrivilege(new AddUserJob(), new Privilege(Privilege::RegisterAccount));
$this->testRegularPrivilege(new EditPostJob(), false);
$this->testRegularPrivilege(new EditUserJob(), false);
$this->testRegularPrivilege(new GetLogJob(), new Privilege(Privilege::ViewLog));
$this->testRegularPrivilege(new ListCommentsJob(), new Privilege(Privilege::ListComments));
@ -42,12 +41,27 @@ class ApiPrivilegeTest extends AbstractFullApiTest
$this->login($this->mockUser());
$this->testDynamicPostPrivilege(new DeletePostJob(), new Privilege(Privilege::DeletePost));
$this->testDynamicPostPrivilege(new EditPostJob(), new Privilege(Privilege::EditPost));
$this->testDynamicPostPrivilege(new EditPostContentJob(), new Privilege(Privilege::EditPostContent));
$this->testDynamicPostPrivilege(new EditPostRelationsJob(), new Privilege(Privilege::EditPostRelations));
$this->testDynamicPostPrivilege(new EditPostSafetyJob(), new Privilege(Privilege::EditPostSafety));
$this->testDynamicPostPrivilege(new EditPostSourceJob(), new Privilege(Privilege::EditPostSource));
$this->testDynamicPostPrivilege(new EditPostTagsJob(), new Privilege(Privilege::EditPostTags));
$this->testDynamicPostPrivilege(new EditPostThumbJob(), new Privilege(Privilege::EditPostThumb));
$ctx = function($job)
{
$job->setContext(AbstractJob::CONTEXT_BATCH_ADD);
return $job;
};
$this->testDynamicPostPrivilege($ctx(new EditPostJob), new Privilege(Privilege::AddPost));
$this->testDynamicPostPrivilege($ctx(new EditPostContentJob), new Privilege(Privilege::AddPostContent));
$this->testDynamicPostPrivilege($ctx(new EditPostRelationsJob), new Privilege(Privilege::AddPostRelations));
$this->testDynamicPostPrivilege($ctx(new EditPostSafetyJob), new Privilege(Privilege::AddPostSafety));
$this->testDynamicPostPrivilege($ctx(new EditPostSourceJob), new Privilege(Privilege::AddPostSource));
$this->testDynamicPostPrivilege($ctx(new EditPostTagsJob), new Privilege(Privilege::AddPostTags));
$this->testDynamicPostPrivilege($ctx(new EditPostThumbJob), new Privilege(Privilege::AddPostThumb));
$this->testDynamicPostPrivilege(new FeaturePostJob(), new Privilege(Privilege::FeaturePost));
$this->testDynamicPostPrivilege(new FlagPostJob(), new Privilege(Privilege::FlagPost));
$this->testDynamicPostPrivilege(new ScorePostJob(), new Privilege(Privilege::ScorePost));

View file

@ -0,0 +1,55 @@
<?php
class AddPostJobTest extends AbstractTest
{
public function testSaving()
{
$this->prepare();
$this->grantAccess('addPost');
$this->grantAccess('addPostSafety');
$this->grantAccess('addPostTags');
$this->grantAccess('addPostSource');
$this->grantAccess('addPostContent');
$args =
[
AddPostJob::ANONYMOUS => false,
EditPostSafetyJob::SAFETY => PostSafety::Safe,
EditPostSourceJob::SOURCE => '',
EditPostContentJob::POST_CONTENT => new ApiFileInput($this->getPath('image.jpg'), 'test.jpg'),
];
$this->assert->doesNotThrow(function() use ($args)
{
Api::run(new AddPostJob(), $args);
});
}
public function testPrivilegeFail()
{
$this->prepare();
$this->grantAccess('addPost');
$this->grantAccess('addPostSafety');
$this->grantAccess('addPostTags');
$this->grantAccess('addPostContent');
$args =
[
AddPostJob::ANONYMOUS => false,
EditPostSafetyJob::SAFETY => PostSafety::Safe,
EditPostSourceJob::SOURCE => '',
EditPostContentJob::POST_CONTENT => new ApiFileInput($this->getPath('image.jpg'), 'test.jpg'),
];
$this->assert->throws(function() use ($args)
{
Api::run(new AddPostJob(), $args);
}, 'Insufficient privilege');
}
protected function prepare()
{
getConfig()->registration->needEmailForUploading = false;
}
}

View file

@ -162,9 +162,4 @@ class EditPostContentJobTest extends AbstractTest
return $post;
}
protected function getPath($name)
{
return getConfig()->rootDir . DS . 'tests' . DS . 'TestFiles' . DS . $name;
}
}

View file

@ -0,0 +1,50 @@
<?php
class EditPostJobTest extends AbstractTest
{
public function testSaving()
{
$this->grantAccess('editPost');
$this->grantAccess('editPostSafety');
$this->grantAccess('editPostTags');
$this->grantAccess('editPostSource');
$this->grantAccess('editPostContent');
$post = $this->mockPost(Auth::getCurrentUser());
$args =
[
EditPostJob::POST_ID => $post->getId(),
EditPostSafetyJob::SAFETY => PostSafety::Safe,
EditPostSourceJob::SOURCE => '',
EditPostContentJob::POST_CONTENT => new ApiFileInput($this->getPath('image.jpg'), 'test.jpg'),
];
$this->assert->doesNotThrow(function() use ($args)
{
Api::run(new EditPostJob(), $args);
});
}
public function testPrivilegeFail()
{
$this->grantAccess('editPost');
$this->grantAccess('editPostSafety');
$this->grantAccess('editPostTags');
$this->grantAccess('editPostContent');
$post = $this->mockPost(Auth::getCurrentUser());
$args =
[
EditPostJob::POST_ID => $post->getId(),
EditPostSafetyJob::SAFETY => PostSafety::Safe,
EditPostSourceJob::SOURCE => '',
EditPostContentJob::POST_CONTENT => new ApiFileInput($this->getPath('image.jpg'), 'test.jpg'),
];
$this->assert->throws(function() use ($args)
{
Api::run(new EditPostJob(), $args);
}, 'Insufficient privilege');
}
}