How can I test a file upload function with a controller test case in CakePHP 3?
I keep running into the problem that PHP thinks the file was not actually uploaded. The validation rules that works for a browser test, but not for a test case:
->add('file', [
'is_uploaded_file' => [
'rule' => ['uploadedFile', ['optional' => false]],
'message' => 'File is no valid uploaded file'
],
I quickly found out that the is_uploaded_file and move_uploaded_file are impossible to fool in a unit test.
However, most topics on this are old and/or not about CakePHP specifically, so I figured I'd post a new question.
You don't necessarily need to modify validation rules, what you can do alternatively is using an object that implements \Psr\Http\Message\UploadedFileInterface. CakePHP's default uploaded file validation supports such objects.
CakePHP requires zendframework/zend-diactoros, so you can use \Zend\Diactoros\UploadedFile and do something like this in your tests:
$data = [
// ...
'file' => new \Zend\Diactoros\UploadedFile([
'/path/to/the/temporary/file/on/disk',
1234, // filesize in bytes
\UPLOAD_ERR_OK, // upload (error) status
'filename.jpg', // upload filename
'image/jpeg' // upload mime type
])
];
The uploadedFile rule will automatically treat such an object as an uploaded file.
Of course your code that handles the file upload must support that interface too, but it's not that complicated, you just need to make sure that the regular file upload arrays are being converted into UploadedFileInterface implementations so that your upload handler can make that a requirement.
It could of course be done in the upload handler itself, so that validation will use regular file upload arrays as well as UploadedFile objects. Another way would be to convert them earlier when creating entities, using the beforeMarshal handler/event, something along the lines of this:
public function beforeMarshal(\Cake\Event\Event $event, \ArrayObject $data, \ArrayObject $options)
{
$file = \Cake\Utility\Hash::get($data, 'file');
if ($file === null) {
return;
}
if (!($file instanceof \Psr\Http\Message\UploadedFileInterface)) {
if (!is_uploaded_file(\Cake\Utility\Hash::get($file, 'tmp_name'))) {
$file = new \Zend\Diactoros\UploadedFile(
null,
0,
UPLOAD_ERR_NO_FILE,
null,
null
);
} else {
$file = new \Zend\Diactoros\UploadedFile(
\Cake\Utility\Hash::get($file, 'tmp_name'),
\Cake\Utility\Hash::get($file, 'size'),
\Cake\Utility\Hash::get($file, 'error'),
\Cake\Utility\Hash::get($file, 'name'),
\Cake\Utility\Hash::get($file, 'type')
);
}
$data['file'] = $file;
}
}
This will convert the data into an UploadedFile object in case it's an actually uploaded file. This extra check is added because CakePHP's behavior of merging file data with the POST data, making it impossible (unless one can access the request object, or the $_FILES superglobal) to determine whether a user posted that data, or whether PHP generated that data for an actual file upload.
If you then use \Psr\Http\Message\UploadedFileInterface::moveTo() to move the file, it will work in SAPI (browser based) as well as non-SAPI (CLI) environments:
try {
$file->moveTo($targetPath);
} catch (\Exception $exception) {
$entity->setError(
'file', [__('The file could not be moved to its destination.')]
);
}
See also
PSR-7: HTTP message interfaces > Uploaded Files
API > \Cake\Validatiom\Validation::uploadedFile()
I actually figured it out almost immediately after I'd posted.
The solution is based on https://pierrerambaud.com/blog/php/2012-12-29-testing-upload-file-with-php
So the only way to get around the problem is overriding both the built-in functions: is_uploaded_file and move_uploaded_file.
The uploadedFile validation rule lives inside Cake\Validation, and I'm using the move function in a table event, so inside App\Model\Table.
I added the following to the top of the controller test case:
<?php
namespace Cake\Validation;
function is_uploaded_file($filename)
{
return true;
}
namespace App\Model\Table;
function move_uploaded_file($filename, $destination)
{
return copy($filename, $destination);
}
namespace App\Test\TestCase\Controller;
use App\Controller\CarsController;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;
use Cake\Core\Configure;
/**
* App\Controller\CarsController Test Case
*/
class CarsControllerTest extends BaseTestCase
{
use IntegrationTestTrait;
// ...
And it works!
Related
I'm working on my cakephp project
and I am currently upgrading from 3.3.16 to 3.4.0
The project uses the cakephp-upload plugin to save an image.
The Upload plugin needs an existing entity to attach a file to it. A modification of the request is done to grab the avatar, before unsetting it to save the user.
I know this is not a good practice to modify a request, but the code was made this way.
With immutable objects in version 3.4.0, it is just not possible anymore. But i dont know how to do it properly.
Here is the error message given by my unit-test,
ran by vendor/bin/phpunit --filter testAdd tests/TestCase/Controller/Api/V1/UsersControllerTest.php:
There was 1 failure:
1) App\Test\TestCase\Controller\Api\V1\UsersControllerTest::testAdd
Failed asserting that file "/home/comptoir/Comptoir-srv/webroot/img/files/Users/photo/5/avatar/correctAvatarLogo.jpg" exists.
/home/comptoir/Comptoir-srv/tests/TestCase/Controller/Api/V1/UsersControllerTest.php:208
Here is the actual code:
public function add()
{
if (!empty($this->request->data)) {
$user = $this->Users->newEntity($this->request->data);
} else {
$user = $this->Users->newEntity();
}
$message = "";
// Get the avatar before unset it to save the user.
// The Upload plugin need an existing entity to attach a file to it.
if ($this->request->is('post')) {
if (isset($this->request->data['photo']) && !$user->errors()) {
$avatar = $this->request->data['photo'];
$this->request->data['photo'] = "";
}
$user = $this->Users->patchEntity($user, $this->request->data);
if ($this->Users->save($user)) {
$user = $this->Users->get($user->id, ['contain' => []]);
isset($avatar) ? $this->request->data['photo'] = $avatar : null;
$user = $this->Users->patchEntity($user, $this->request->data);
if ($this->Users->save($user)) {
$message = "Success";
$this->Flash->success(__d("Forms", "Your are registred on the Comptoir du Libre, welcome !"));
if (!$this->request->is('json')) {
$this->Auth->setUser($this->Auth->identify());
$this->redirect([
"prefix" => false,
"controller" => "Pages",
"language" => $this->request->param("language")
]);
}
} else {
$message = "Error";
}
} else {
$message = "Error";
$this->Flash->error(__d("Forms", "Your registration failed, please follow rules in red."));
}
$message == "Error" ? $this->set('errors', $user->errors()) : null;
}
$this->ValidationRules->config('tableRegistry', "Users");
$rules = $this->ValidationRules->get();
$userTypes = $this->Users->UserTypes->find('list', ['limit' => 200]);
$this->set(compact('user', 'userTypes', 'rules', 'message'));
$this->set('_serialize', ['user', 'userTypes', 'rules', 'message', 'errors']);
}
Does anyone know how to do that respecting the immutable rule ?
Your premise is wrong.
The Upload plugin needs an existing entity to attach a file to it
That's actually not correct, uploading files alongside creating new records works fine. There's no need for this stuff in your controller, it should be possible to handle this with a single basic save, ie you should investigate the problem that you're having with that, and fix it.
However looking at your test, it should fail anyways, because the file data that you're passing is invalid, it's neither an actual uploaded file for which is_uploaded_file() would return true, nor is it acceptable for user data to be able to define the temporary file path, and the error code, ie you're not properly validating the data if that test passes as is. Accepting such data is a security vulnerability, it could allow all sorts of attacks, from path traversal to arbitrary file injections!
Ideally your whole upload validation and writing functionality would support \Psr\Http\Message\UploadedFileInterface objects, that would allow for very simply testing by being able to pass instances of that class into the test data, that might be something worth suggesting for the plugin. Without such functionality, your second best bet would probably be something like modifying the table's validation rules before issuing the test request, so that is_uploaded_file() is being skipped, or you're switching to integration tests over HTTP, instead of the simulation in CakePHP.
I have the following code that sends my email:
/**
* #param array $to
* #param string $subject
* #param array $vars
* #param string $template
* #param array $from
*/
public function sendEmail(array $to, $subject, array $vars, $template = 'default', array $from = ['dev#example.com' => 'Online'])
{
$transport = 'default';
if (Configure::read('debug')) {
$transport = 'dev';
}
$mailer = new Email($transport);
if ($this->isCommandLineInterface()) {
$mailer->setDomain('http://local.peepznew.com');
}
$this->addRecipients($mailer, $to);
$mailer->setFrom($from);
$mailer->setSubject($subject);
$mailer->setTemplate($template);
if (isset($vars['preheaderText']) === false) {
$vars['preheaderText'] = '';
}
$vars['subject'] = $subject;
$mailer->setViewVars($vars);
$mailer->setEmailFormat('both');
$mailer->send();
}
This code is called from the web interface as well as from the command line. After struggling to get the full url to display in messages sent from the command line, I read the docs and came across this:
Which is why I'm doing the setDomain call. I run my code again, and it still doesn't have full Urls. So I created the exact same function in both the web interface and cli, that looks like this:
$this->sendEmail(
['my.email#example.com'],
'Test Email',
[
'title' => 'We need to select another peep',
'showFooterLinks' => true,
]
);
die;
The default template looks like so (it literally only has this one line in it):
echo $this->Html->link('test link', ['controller' => 'jobs', 'action' => 'select_staff', 1, '_full' => true]);
The emails from the web interface, using the code above, sends perfect. Full URLs and everything. However from the cli, it just sends /jobs/select_staff/1.
Why is this and how do I fix it?
Read the docs closely, they say that the domain set via setDomain() is being used when generating message IDs, ie it's being used in an E-Mail header.
Generating links is something completely different, and is affected by the App.fullBaseUrl configuration option, which is by default derived from env('HTTP_HOST') in your applications config/bootstrap.php, unless already configured in config/app.php.
It's also possible to configure the base URL separately for the CLI environment in your config/bootstrap_cli.php file, there should already be a commented snippet for doing so that looks like this:
// Set the fullBaseUrl to allow URLs to be generated in shell tasks.
// This is useful when sending email from shells.
//Configure::write('App.fullBaseUrl', php_uname('n'));
See also
Cookbook > Console Tools, Shells & Tasks > Routing in the Console Environment
Source > cakephp/app > config/bootstrap.php
Source > cakephp/app > config/bootstrap_cli.php
Source > cakephp/app > config/app.php
I want to download file PDF from frontend/web/uploads. And I have an error in the controller (maybe):
Calling unknown method: frontend\controllers\BukuController::findModel()
This is my source code:
BukuController.php
public function actionDownload($id)
{
$model = $this->findModel($id);
$file ='../frontend/uploads/'.$model->file_buku;
if(file_exists($file))
{
return Yii::$app->response->sendFile($file);
exit;
}
}
And this is function in views, views/buku/index.php:
<?= Html::a('Download', ['download','id'=> $buku->file_buku]); ?>
(Solved)
As the error state, you missing in your BukuController method called findModel.
This method should search the model in the DB.
Something like this:
protected function findModel($id)
{
if (!is_null($model = Buku::findOne($id))) {
return $model;
}
throw new NotFoundHttpException('The requested page does not exist.');
}
Of course if your model is not Buku, you need to change it relatively, and also import it in the top of the controller file:
use app\models\Buku; // Basic application.
use common\models\Buku; // Advanced application when models store in common folder.
use frontend\models\Buku; // Advanced application when models store in frontend folder.
Change $file variable to this.
$file =Yii::$app->getBasePath().'/web/uploads/'.$model->file_buku;
I want to serve JSONP content with CakePHP and was wondering what's the proper way of doing it so.
Currently I'm able to serve JSON content automatically by following this CakePHP guide.
Ok, I found a solution on this site. Basically you override the afterFilter method with:
public function afterFilter() {
parent::afterFilter();
if (empty($this->request->query['callback']) || $this->response->type() != 'application/json') {
return;
}
// jsonp response
App::uses('Sanitize', 'Utility');
$callbackFuncName = Sanitize::clean($this->request->query['callback']);
$out = $this->response->body();
$out = sprintf("%s(%s)", $callbackFuncName, $out);
$this->response->body($out);
}
I hope it helps someone else as well.
I've as yet not found a complete example of how to correctly return JSONP using CakePHP 2, so I'm going to write it down. OP asks for the correct way, but his answer doesn't use the native options available now in 2.4. For 2.4+, this is the correct method, straight from their documentation:
Set up your views to accept/use JSON (documentation):
Add Router::parseExtensions('json'); to your routes.php config file. This tells Cake to accept .json URI extensions
Add RequestHandler to the list of components in the controller you're going to be using
Cake gets smart here, and now offers you different views for normal requests and JSON/XML etc. requests, allowing you flexibility in how to return those results, if needed. You should now be able to access an action in your controller by:
using the URI /controller/action (which would use the view in /view/controller/action.ctp), OR
using the URI /controller/action.json (which would use the view in /view/controller/json/action.ctp)
If you don't want to define those views i.e. you don't need to do any further processing, and the response is ready to go, you can tell CakePHP to ignore the views and return the data immediately using _serialize. Using _serialize will tell Cake to format your response in the correct format (XML, JSON etc.), set the headers and return it as needed without you needing to do anything else (documentation). To take advantage of this magic:
Set the variables you want to return as you would a view variable i.e. $this->set('post', $post);
Tell Cake to serialize it into XML, JSON etc. by calling $this->set('_serialize', array('posts'));, where the parameter is the view variable you just set in the previous line
And that's it. All headers and responses will be taken over by Cake. This just leaves the JSONP to get working (documentation):
Tell Cake to consider the request a JSONP request by setting $this->set('_jsonp', true);, and Cake will go find the callback function name parameter, and format the response to work with that callback function name. Literally, setting that one parameter does all the work for you.
So, assuming you've set up Cake to accept .json requests, this is what your typical action could look like to work with JSONP:
public function getTheFirstPost()
$post = $this->Post->find('first');
$this->set(array(
'post' => $post, <-- Set the post in the view
'_serialize' => array('post'), <-- Tell cake to use that post
'_jsonp' => true <-- And wrap it in the callback function
)
);
And the JS:
$.ajax({
url: "/controller/get-the-first-post.json",
context: document.body,
dataType: 'jsonp'
}).done(function (data) {
console.log(data);
});
For CakePHP 2.4 and above, you can do this instead.
http://book.cakephp.org/2.0/en/views/json-and-xml-views.html#jsonp-response
So you can simply write:
$this->set('_jsonp', true);
in the relevant action.
Or you can simply write:
/**
*
* beforeRender method
*
* #return void
*/
public function beforeRender() {
parent::beforeRender();
$this->set('_jsonp', true);
}
I just wanted to know if there is a way to limit the file input dialog to show only certain kinds of files. My web page only can accept .bin or .gz file types, but the user can select other file types and try to upload them.
What would be the best way to prevent the wrong kind of file from being uploaded?
Here is my controller for file uploading:
public function uploadFile()
{
$this->Session->write('isFileUpload', false);
$this->Session->write('isFileLarge', false);
if($this->request->is('post'))
{
$uploadedFile = array();
// Check if the Document object is set
// If it is set, process the file for uploading,
if(isset($this->request->data['Document']))
{
$filename = $this->request->data['Document']['MyFile']['tmp_name'];
$uploadedFile['MyFile']['name'] = $this->request->data['Document']['MyFile']['name'];
$uploadedFile['MyFile']['type'] = $this->request->data['Document']['MyFile']['type'];
$uploadedFile['MyFile']['size'] = $this->request->data['Document']['MyFile']['size'];
// Move the file to the /home/spectracom folder
$filePath = DS . 'home' . DS . $uploadedFile['MyFile']['name'];
if (move_uploaded_file($filename, $filePath))
{
$this->Session->write('isFileUpload', true);
$this->Session->write('isFileLarge', false);
$this->redirect('/tools/upgradebackup');
}
else
{
$this->Session->write('isFileUpload', false);
$this->Session->write('isFileLarge', true);
$this->redirect('/tools/upgradebackup');
}
}
else
{
$this->Session->write('isFileUpload', false);
$this->Session->write('isFileLarge', true);
$this->redirect('/tools/upgradebackup');
}
}
}
I basically check that the file exists, or else it is too large, and when it returns to the main upgrade page it sets the session variables.
Thanks
You limit what the browser allows the user to select in the file selection dialog using the accept attribute, although not all browsers support it.
I think this should work for creating the input (you'll need to play around with the MIME types to see what works):
echo $this->Form->input('MyFile', array('type' => 'file', 'options' => array('accept' => 'application/gzip,application/gzipped,application/octet-stream')));
You should also validate the files once they arrive on the server by setting up validation on your model (look at extension and mimeType validation rules).
You can also use JavaScript to validate the file extension once it has been selected by the user, and clear the file input field if it has the wrong extension.
Tested with Cakephp 3.4
$this->Form->control('my_file', ['label' => 'Upload File','type' => 'file', 'accept' => 'application/msword']);