Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117754475
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
47 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Http/DAV/Auth.php b/src/app/Http/DAV/Auth.php
index 2b606d62..4744b2cb 100644
--- a/src/app/Http/DAV/Auth.php
+++ b/src/app/Http/DAV/Auth.php
@@ -1,72 +1,72 @@
<?php
namespace App\Http\DAV;
use App\User;
use Sabre\DAV\Auth\Backend\AbstractBasic;
/**
* Basic Authentication for WebDAV
*/
class Auth extends AbstractBasic
{
// Make the current user available to all classes
public static $user;
/**
* Authentication Realm.
*
* The realm is often displayed by browser clients when showing the
* authentication dialog.
*
* @var string
*/
protected $realm = 'Kolab/DAV';
/**
* This is the prefix that will be used to generate principal urls.
*
* @var string
*/
protected $principalPrefix = 'dav/principals/';
/**
* Validates a username and password
*
* This method should return true or false depending on if login
* succeeded.
*
* @param string $username
* @param string $password
*/
protected function validateUserPass($username, $password): bool
{
// Note: For now authenticating user must match the path user
if (str_contains($username, '@') && $username === $this->getPathUser()) {
$auth = User::findAndAuthenticate($username, $password);
if (!empty($auth['user'])) {
self::$user = $auth['user'];
// Cyrus DAV principal location
$this->principalPrefix = 'dav/principals/user/' . $username;
return true;
}
}
return false;
}
/**
* Extract user (email) from the request path.
*/
protected static function getPathUser(): string
{
$path = \request()->path();
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/';
$path = substr($path, strlen($root));
- return explode('/', $path)[0];
+ return rawurldecode(explode('/', $path)[0]);
}
}
diff --git a/src/tests/Feature/Controller/DAVTest.php b/src/tests/Feature/Controller/DAVTest.php
index bf268311..b571d0d2 100644
--- a/src/tests/Feature/Controller/DAVTest.php
+++ b/src/tests/Feature/Controller/DAVTest.php
@@ -1,967 +1,969 @@
<?php
namespace Tests\Feature\Controller;
use App\Fs\Item;
use App\Fs\Lock;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCaseFs;
class DAVTest extends TestCaseFs
{
/**
* Test basic COPY requests
*/
public function testCopy(): void
{
$host = trim(\config('app.url'), '/');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
$john = $this->getTestUser('john@kolab.org');
[$folders, $files] = $this->initTestStorage($john);
// Test with no Authorization header
$response = $this->davRequest('COPY', "{$root}/test1.txt", '', null, ['Destination' => "{$host}/{$root}/copied1.txt"]);
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test copying a non-existing file
$response = $this->davRequest('COPY', "{$root}/unknown", '', $john, ['Destination' => "{$host}/{$root}/copied.txt"]);
$response->assertNoContent(404);
// Test a file copy into non-existing location
$response = $this->davRequest('COPY', "{$root}/test1.txt", '', $john, ['Destination' => "{$host}/{$root}/unknown/test1.txt"]);
$response->assertNoContent(409);
// Test a file copy "in place" with rename
$response = $this->davRequest('COPY', "{$root}/test1.txt", '', $john, ['Destination' => "{$host}/{$root}/copied1.txt"]);
$response->assertNoContent(201);
$all_items = array_merge(array_column($files, 'id'), array_column($folders, 'id'));
$file = $john->fsItems()->whereNotIn('id', $all_items)->first();
$this->assertSame($files[0]->type, $file->type);
$this->assertSame($files[0]->updated_at->getTimestamp(), $file->updated_at->getTimestamp());
$this->assertCount(0, $file->parents()->get());
$this->assertSame('copied1.txt', $file->getProperty('name'));
$this->assertSame('text/plain', $file->getProperty('mimetype'));
$this->assertSame('13', $file->getProperty('size'));
$all_items[] = $file->id;
$copied = $file;
// Test an empty folder copy "in place" with rename
$empty_folder = $this->getTestCollection($john, 'folder-empty');
$all_items[] = $empty_folder->id;
$response = $this->davRequest('COPY', "{$root}/folder-empty", '', $john, ['Destination' => "{$host}/{$root}/folder-copy", 'Depth' => 'infinity']);
$response->assertNoContent(201);
$folder = $john->fsItems()->whereNotIn('id', $all_items)->first();
$this->assertSame($empty_folder->type, $folder->type);
$this->assertSame($empty_folder->updated_at->getTimestamp(), $folder->updated_at->getTimestamp());
$this->assertSame('folder-copy', $folder->getProperty('name'));
$this->assertCount(0, $folder->parents()->get());
// Copying non-empty folders
// Add an extra file into /folder1/folder2 folder
$file = $this->getTestFile($john, 'test5.txt', 'Test con5', ['mimetype' => 'text/plain']);
$folders[1]->children()->attach($file);
$response = $this->davRequest('COPY', "{$root}/folder1", '', $john, ['Destination' => "{$host}/{$root}/folder-copy/folder1", 'Depth' => 'infinity']);
$response->assertNoContent(201);
$this->assertCount(1, $children = $folder->children()->get());
$copy = $children[0];
$this->assertSame(Item::TYPE_COLLECTION, $copy->type);
$this->assertSame('folder1', $copy->getProperty('name'));
$this->assertCount(1, $copy->parents()->get());
$children = $copy->children()->select('fs_items.*')
->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
. ' and fs_properties.key = \'name\') as name')
->orderBy('name')
->get()
->keyBy('name');
$this->assertSame(['folder2', 'test3.txt', 'test4.txt'], $children->pluck('name')->all());
$children = $children['folder2']->children()->select('fs_items.*')
->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
. ' and fs_properties.key = \'name\') as name')
->orderBy('name')
->get()
->keyBy('name');
$this->assertSame(['test5.txt'], $children->pluck('name')->all());
$this->assertSame('Test con5', $this->getTestFileContent($children['test5.txt']));
// Copy a file into an existing location (with "Overwrite:F" header)
$response = $this->davRequest('COPY', "{$root}/test2.txt", '', $john, ['Destination' => "{$host}/{$root}/copied1.txt", 'Overwrite' => 'F']);
$response->assertStatus(412);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Copy a file into an existing location (without "Overwrite:F" header)
$response = $this->davRequest('COPY', "{$root}/folder1/folder2/test5.txt", '', $john, ['Destination' => "{$host}/{$root}/copied1.txt"]);
$response->assertNoContent(204);
$this->assertTrue($copied->fresh()->trashed());
$copied = $john->fsItems()
->whereRaw('id in (select item_id from fs_properties where `key` = \'name\' and `value` = \'copied1.txt\')')
->get();
$this->assertCount(1, $copied);
$this->assertTrue($children['test5.txt']->id != $copied[0]->id);
$this->assertSame('copied1.txt', $copied[0]->getProperty('name'));
$this->assertSame('Test con5', $this->getTestFileContent($copied[0]));
// TODO: Test copying a collection with Depth:0 (and Depth:1) header
// TODO: Make sure a copy /A/ into /A/B/ does not lead to an infinite recursion
$this->markTestIncomplete();
}
/**
* Test basic DELETE requests
*/
public function testDelete(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
[$folders, $files] = $this->initTestStorage($john);
$folders[] = $this->getTestCollection($john, 'folder3');
$files[] = $this->getTestFile($john, 'test5.txt', 'Test');
$folders[0]->children()->attach($folders[2]);
$folders[2]->children()->attach($files[4]);
// Test with no Authorization header
$response = $this->davRequest('DELETE', "{$root}/test1.txt");
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test non-existing location
$response = $this->davRequest('DELETE', "{$root}/unknown", '', $john);
$response->assertNoContent(404);
// Test deleting a file in the root
$response = $this->davRequest('DELETE', "{$root}/test1.txt", '', $john);
$response->assertNoContent(204);
$this->assertTrue($files[0]->fresh()->trashed());
// Test deleting a file in a folder
$response = $this->davRequest('DELETE', "{$root}/folder1/test3.txt", '', $john);
$response->assertNoContent(204);
$this->assertTrue($files[2]->fresh()->trashed());
// Test deleting a folder
$response = $this->davRequest('DELETE', "{$root}/folder1", '', $john);
$response->assertNoContent(204);
$this->assertTrue($folders[0]->fresh()->trashed());
$this->assertTrue($folders[1]->fresh()->trashed());
$this->assertTrue($folders[2]->fresh()->trashed());
$this->assertTrue($files[3]->fresh()->trashed());
$this->assertTrue($files[4]->fresh()->trashed());
$this->assertFalse($files[1]->fresh()->trashed());
// Test deleting the root
$response = $this->davRequest('DELETE', $root, '', $john);
$response->assertNoContent(403);
$this->assertFalse($files[1]->fresh()->trashed());
}
/**
* Test basic DELETE requests to locked nodes
*/
public function testDeleteLocked(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Create the following structure
// - folder1/
// - folder2/
// - test1.txt
// - test2.txt
$folder1 = $this->getTestCollection($john, 'folder1');
$folder2 = $this->getTestCollection($john, 'folder2');
$file1 = $this->getTestFile($john, 'test1.txt', 'Test');
$file2 = $this->getTestFile($john, 'test2.txt', 'Test');
$folder1->children()->attach($folder2);
$folder2->children()->attach([$file1, $file2]);
$lock1 = $folder1->locks()->create([
'token' => 'testtoken',
'depth' => 0,
'scope' => Lock::SCOPE_SHARED,
'timeout' => 1800,
'owner' => 'test',
]);
// Test deleting a locked folder
$response = $this->davRequest('DELETE', "{$root}/folder1", '', $john);
$response->assertStatus(423);
// TODO: Assert XML response
$this->assertFalse($folder1->fresh()->trashed());
// Test deleting a file in a locked folder
$response = $this->davRequest('DELETE', "{$root}/folder1/folder2/test1.txt", '', $john);
$response->assertStatus(423);
// TODO: Assert XML response
$this->assertFalse($file1->fresh()->trashed());
// Test deleting a folder that has a locked child
$lock1->delete();
$lock2 = $file1->locks()->create([
'token' => 'testtoken',
'depth' => 0,
'scope' => Lock::SCOPE_SHARED,
'timeout' => 1800,
'owner' => 'test',
]);
$response = $this->davRequest('DELETE', "{$root}/folder1", '', $john);
$response->assertStatus(423);
// TODO: Assert XML response
$this->assertFalse($folder1->fresh()->trashed());
// TODO: Test successful deletion with 'If' header
}
/**
* Test basic GET requests
*/
public function testGet(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
$content = [
'Test content1',
'Test content2',
'Test content3',
];
$content_length = strlen(implode('', $content));
$file = $this->getTestFile($john, 'test1.txt', $content, ['mimetype' => 'text/plain']);
// Test with no Authorization header
$response = $this->davRequest('GET', "{$root}/test1.txt");
$response->assertStatus(401);
$response->assertHeaderMissing('Content-Security-Policy');
$response->assertHeader('WWW-Authenticate', 'Basic realm="Kolab/DAV", charset="UTF-8"');
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test non-existing location
$response = $this->davRequest('GET', "{$root}/unknown", '', $john);
$response->assertNoContent(404);
// Test with a valid Authorization header
$response = $this->davRequest('GET', "{$root}/test1.txt", '', $john);
$response->assertStatus(200);
$response->assertStreamedContent(implode('', $content));
// Test Range header
$range = 'bytes=0-' . $content_length - 1;
$response = $this->davRequest('GET', "{$root}/test1.txt", '', $john, ['Range' => $range]);
$response->assertStatus(206);
$response->assertStreamedContent(implode('', $content));
$response->assertHeader('Content-Length', $content_length);
$response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
$range = 'bytes=5-' . strlen($content[0]) - 1;
$response = $this->davRequest('GET', "{$root}/test1.txt", '', $john, ['Range' => $range]);
$response->assertStatus(206);
$response->assertStreamedContent('content1');
$response->assertHeader('Content-Length', strlen('content1'));
$response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
$range = sprintf('bytes=%d-%d', strlen($content[0]), strlen($content[0]) + 3);
$response = $this->davRequest('GET', "{$root}/test1.txt", '', $john, ['Range' => $range]);
$response->assertStatus(206);
$response->assertStreamedContent('Test');
$response->assertHeader('Content-Length', strlen('Test'));
$response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
$range = 'bytes=12-26';
$response = $this->davRequest('GET', "{$root}/test1.txt", '', $john, ['Range' => $range]);
$response->assertStatus(206);
$response->assertStreamedContent('1Test content2T');
$response->assertHeader('Content-Length', strlen('1Test content2T'));
$response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
// Test GET on a collection
$folder = $this->getTestCollection($john, 'folder1');
$response = $this->davRequest('GET', "{$root}/folder1", '', $john);
$response->assertStatus(501);
// TODO: Test big files >10MB
}
/**
* Test basic HEAD requests
*/
public function testHead(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
$file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
// Test with no Authorization header
$response = $this->davRequest('HEAD', "{$root}/test1.txt");
$response->assertNoContent(401);
// Test non-existing location
$response = $this->davRequest('HEAD', "{$root}/unknown", '', $john);
$response->assertNoContent(404);
// Test with a valid Authorization header
$response = $this->davRequest('HEAD', "{$root}/test1.txt", '', $john);
$response->assertNoContent(200);
// TODO: Test HEAD on a collection
}
/**
* Test basic LOCK requests (RFC 4918)
*/
public function testLock(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
$file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
$xml = <<<EOF
<d:lockinfo xmlns:d='DAV:'>
<d:lockscope><d:exclusive/></d:lockscope>
<d:locktype><d:write/></d:locktype>
<d:owner>
<d:href>{$root}</d:href>
</d:owner>
</d:lockinfo>
EOF;
// Test with no Authorization header
$response = $this->davRequest('LOCK', "{$root}/test1.txt", $xml);
$response->assertNoContent(401);
// Test locking the root
$response = $this->davRequest('LOCK', $root, $xml, $john);
$response->assertStatus(403);
// Test locking an existing file
$response = $this->davRequest('LOCK', "{$root}/test1.txt", $xml, $john);
$response->assertStatus(200);
$lock = $file->locks()->first();
$this->assertSame("<opaquelocktoken:{$lock->token}>", $response->headers->get('Lock-Token'));
$this->assertSame("<d:href xmlns:d=\"DAV:\">{$root}</d:href>", $lock->owner);
$this->assertSame(1, $lock->depth);
$this->assertSame(Lock::SCOPE_EXCLUSIVE, $lock->scope);
$this->assertSame(1800, $lock->timeout);
$doc = $this->responseXML($response);
$this->assertSame('prop', $doc->documentElement->localName);
$data = $doc->documentElement->getElementsByTagName('lockdiscovery')->item(0)
->getElementsByTagName('activelock')->item(0);
$this->assertSame('write', $data->getElementsByTagName('locktype')->item(0)->firstChild->localName);
$this->assertSame('exclusive', $data->getElementsByTagName('lockscope')->item(0)->firstChild->localName);
$this->assertSame('1', $data->getElementsByTagName('depth')->item(0)->nodeValue);
$this->assertSame($root, $data->getElementsByTagName('owner')->item(0)->firstChild->nodeValue);
$this->assertSame('Second-1800', $data->getElementsByTagName('timeout')->item(0)->nodeValue);
$this->assertSame("opaquelocktoken:{$lock->token}", $data->getElementsByTagName('locktoken')->item(0)->firstChild->nodeValue);
$this->assertSame("/{$root}/test1.txt", $data->getElementsByTagName('lockroot')->item(0)->firstChild->nodeValue);
// Test updating a lock (non-empty body)
$response = $this->davRequest('LOCK', "{$root}/test1.txt", $xml, $john);
$response->assertStatus(423);
// TODO: Assert XML response
// Test updating a lock - empty body as specified in RFC, but missing If header
$response = $this->davRequest('LOCK', "{$root}/test1.txt", '', $john);
$response->assertStatus(423);
// TODO: Assert XML response
// Test updating a lock - empty body as specified in RFC, with valid If header
Carbon::setTestNow(Carbon::createFromDate(2022, 2, 2));
$headers = ['Depth' => '0', 'If' => "(<opaquelocktoken:{$lock->token}>)"];
$response = $this->davRequest('LOCK', "{$root}/test1.txt", '', $john, $headers);
$response->assertStatus(200);
$lock->refresh();
$this->assertSame("<opaquelocktoken:{$lock->token}>", $response->headers->get('Lock-Token'));
$this->assertSame("<d:href xmlns:d=\"DAV:\">{$root}</d:href>", $lock->owner);
$this->assertSame(1, $lock->depth);
$this->assertSame(Lock::SCOPE_EXCLUSIVE, $lock->scope);
$this->assertSame(1800, $lock->timeout);
$this->assertSame('2022-02-02', $lock->created_at->format('Y-m-d'));
// Test non-existing location (expect an empty file created)
$xml = <<<'EOF'
<d:lockinfo xmlns:d='DAV:'>
<d:lockscope><d:shared/></d:lockscope>
<d:locktype><d:write/></d:locktype>
<d:owner>Test Owner</d:owner>
</d:lockinfo>
EOF;
$headers = ['Depth' => 'infinity', 'Timeout' => 'Infinite, Second-4100000000'];
$response = $this->davRequest('LOCK', "{$root}/unknown", $xml, $john, $headers);
$response->assertStatus(201);
$doc = $this->responseXML($response);
$file2 = $john->fsItems()->whereNot('id', $file->id)->first();
$this->assertSame('unknown', $file2->getProperty('name'));
$this->assertSame('0', $file2->getProperty('size'));
$this->assertSame('application/x-empty', $file2->getProperty('mimetype'));
$lock2 = $file2->locks()->first();
$this->assertSame("<opaquelocktoken:{$lock2->token}>", $response->headers->get('Lock-Token'));
$this->assertSame('Test Owner', $lock2->owner);
$this->assertSame(Lock::DEPTH_INFINITY, $lock2->depth);
$this->assertSame(Lock::SCOPE_SHARED, $lock2->scope);
$this->assertSame(1800, $lock2->timeout);
$this->assertSame('prop', $doc->documentElement->localName);
$data = $doc->documentElement->getElementsByTagName('lockdiscovery')->item(0)
->getElementsByTagName('activelock')->item(0);
$this->assertSame('shared', $data->getElementsByTagName('lockscope')->item(0)->firstChild->localName);
$this->assertSame('infinity', $data->getElementsByTagName('depth')->item(0)->nodeValue);
$this->assertSame('Test Owner', $data->getElementsByTagName('owner')->item(0)->nodeValue);
$this->assertSame('Second-1800', $data->getElementsByTagName('timeout')->item(0)->nodeValue);
$this->assertSame("opaquelocktoken:{$lock2->token}", $data->getElementsByTagName('locktoken')->item(0)->firstChild->nodeValue);
$this->assertSame("/{$root}/unknown", $data->getElementsByTagName('lockroot')->item(0)->firstChild->nodeValue);
// TODO: Test lock conflicts
}
/**
* Test various requests to locked nodes
*/
public function testLockFeatures(): void
{
// TODO: PROPFIND with lock related properties
// TODO: Test MOVE on a locked file
// TODO: Test COPY on a locked file
// TODO: PUT/PATCH on a locked file
$this->markTestIncomplete();
}
/**
* Test basic MKCOL requests
*/
public function testMkcol(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Test with no Authorization header
$response = $this->davRequest('MKCOL', "{$root}/folder1");
$response->assertStatus(401);
// Test creating a collection in the root
$response = $this->davRequest('MKCOL', "{$root}/folder1", '', $john);
$response->assertNoContent(201);
$this->assertCount(1, $items = $john->fsItems()->get());
$this->assertSame('folder1', $items[0]->getProperty('name'));
// Test collection already exists case
$response = $this->davRequest('MKCOL', "{$root}/folder1", '', $john);
$response->assertStatus(405);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test creating a collection in another folder
$response = $this->davRequest('MKCOL', "{$root}/folder1/folder2", '', $john);
$response->assertNoContent(201);
$this->assertCount(1, $children = $items[0]->children()->get());
$this->assertSame($john->id, $children[0]->user_id);
$this->assertSame('folder2', $children[0]->getProperty('name'));
}
/**
* Test basic MOVE requests
*/
public function testMove(): void
{
$host = trim(\config('app.url'), '/');
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
[$folders, $files] = $this->initTestStorage($john);
// Test with no Authorization header
$response = $this->davRequest('MOVE', "{$root}/test1.txt", '', null, ['Destination' => "{$host}/{$root}/moved1.txt"]);
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test moving a non-existing file
$response = $this->davRequest('MOVE', "{$root}/unknown", '', $john, ['Destination' => "{$host}/{$root}/moved.txt"]);
$response->assertNoContent(404);
// Test a file rename
$response = $this->davRequest('MOVE', "{$root}/test1.txt", '', $john, ['Destination' => "{$host}/{$root}/moved1.txt"]);
$response->assertNoContent(201);
$this->assertCount(0, $files[0]->parents()->get());
$this->assertSame('moved1.txt', $files[0]->getProperty('name'));
// Test a folder rename
$response = $this->davRequest('MOVE', "{$root}/folder1", '', $john, ['Destination' => "{$host}/{$root}/folder10", 'Depth' => 'infinity']);
$response->assertNoContent(201);
$this->assertCount(0, $folders[0]->parents()->get());
$this->assertSame('folder10', $folders[0]->getProperty('name'));
// Test moving a sub-folder into the root
$response = $this->davRequest('MOVE', "{$root}/folder10/folder2", '', $john, ['Destination' => "{$host}/{$root}/folder20", 'Depth' => 'infinity']);
$response->assertNoContent(201);
$this->assertCount(0, $folders[1]->parents()->get());
$this->assertSame('folder20', $folders[1]->getProperty('name'));
// Test moving a file into the root (with no rename)
$response = $this->davRequest('MOVE', "{$root}/folder10/test3.txt", '', $john, ['Destination' => "{$host}/{$root}/test3.txt", 'Overwrite' => 'F']);
$response->assertNoContent(201);
$this->assertCount(0, $files[2]->parents()->get());
$this->assertSame('test3.txt', $files[2]->getProperty('name'));
$this->assertSame('text/plain', $files[2]->getProperty('mimetype'));
// Test moving a folder from root into another folder (no rename)
$response = $this->davRequest('MOVE', "{$root}/folder20", '', $john, ['Destination' => "{$host}/{$root}/folder10/folder20", 'Depth' => 'infinity']);
$response->assertNoContent(201);
$this->assertSame([$folders[0]->id], $folders[1]->parents()->get()->pluck('id')->all());
$this->assertSame('folder20', $folders[1]->getProperty('name'));
// Test moving a file from the root into another folder
$response = $this->davRequest('MOVE', "{$root}/test2.txt", '', $john, ['Destination' => "{$host}/{$root}/folder10/test20.txt"]);
$response->assertNoContent(201);
$this->assertSame([$folders[0]->id], $files[1]->parents()->get()->pluck('id')->all());
$this->assertSame('test20.txt', $files[1]->getProperty('name'));
$this->assertSame('text/html', $files[1]->getProperty('mimetype'));
// Test moving into an existing location with Overwrite:F header
$response = $this->davRequest('MOVE', "{$root}/moved1.txt", '', $john, ['Destination' => "{$host}/{$root}/test3.txt", 'Overwrite' => 'F']);
$response->assertStatus(412);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
}
/**
* Test basic OPTIONS requests
*/
public function testOptions(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Test with no Authorization header
// FIXME: Looking at https://datatracker.ietf.org/doc/html/rfc3744#section-7.2.1
// it does not seem that this should require an authenticated user, but allowing OPTIONS
// on any location to anyone might not be the best idea either. Should we at least allow
// unauthenticated OPTIONS on the root?
$response = $this->davRequest('OPTIONS', $root);
$response->assertStatus(401);
// Test with valid Authorization header
$response = $this->davRequest('OPTIONS', $root, '', $john);
$response->assertNoContent(200);
// TODO: Verify the supported feature set
$this->assertSame('1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, 2', $response->headers->get('DAV'));
}
/**
* Test basic PROPFIND requests on the root location
*/
public function testPropfindOnTheRootFolder(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Test with no Authorization header
$response = $this->davRequest('PROPFIND', $root, '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>');
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test with valid Authorization header, non-existing location
$response = $this->davRequest('PROPFIND', "{$root}/unknown", '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
$response->assertStatus(404);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
[$folders, $files] = $this->initTestStorage($john);
// Test with valid Authorization header
- $response = $this->davRequest('PROPFIND', $root, '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
+ // Also make sure that encoded username is working
+ $enc_root = str_replace('@', '%40', $root);
+ $response = $this->davRequest('PROPFIND', $enc_root, '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
$response->assertStatus(207);
$doc = $this->responseXML($response);
$this->assertSame('multistatus', $doc->documentElement->localName);
$this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
// the root folder
$this->assertSame("/{$root}/", $responses[0]->getElementsByTagName('href')->item(0)->textContent);
$this->assertCount(1, $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes);
$this->assertSame('collection', $responses[0]->getElementsByTagName('resourcetype')->item(0)->firstChild->localName);
$this->assertCount(1, $responses[0]->getElementsByTagName('prop')->item(0)->childNodes);
$this->assertStringContainsString('200 OK', $responses[0]->getElementsByTagName('status')->item(0)->textContent);
// the subfolder folder
$this->assertSame("/{$root}/folder1/", $responses[1]->getElementsByTagName('href')->item(0)->textContent);
$this->assertCount(1, $responses[1]->getElementsByTagName('resourcetype')->item(0)->childNodes);
$this->assertSame('collection', $responses[1]->getElementsByTagName('resourcetype')->item(0)->firstChild->localName);
$prop = $responses[1]->getElementsByTagName('prop')->item(0);
$this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('getlastmodified')->item(0)->textContent);
$this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('creationdate')->item(0)->textContent);
$this->assertStringContainsString('200 OK', $responses[1]->getElementsByTagName('status')->item(0)->textContent);
$this->assertCount(0, $prop->getElementsByTagName('contentlength'));
$this->assertCount(0, $prop->getElementsByTagName('getcontenttype'));
$this->assertCount(0, $prop->getElementsByTagName('getetag'));
// the files
$this->assertSame("/{$root}/test1.txt", $responses[2]->getElementsByTagName('href')->item(0)->textContent);
$this->assertCount(0, $responses[2]->getElementsByTagName('resourcetype')->item(0)->childNodes);
$prop = $responses[2]->getElementsByTagName('prop')->item(0);
$this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('getlastmodified')->item(0)->textContent);
$this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('creationdate')->item(0)->textContent);
$this->assertSame('13', $prop->getElementsByTagName('getcontentlength')->item(0)->textContent);
$this->assertSame('text/plain', $prop->getElementsByTagName('getcontenttype')->item(0)->textContent);
$this->assertCount(1, $prop->getElementsByTagName('getetag'));
$this->assertStringContainsString('200 OK', $responses[2]->getElementsByTagName('status')->item(0)->textContent);
$this->assertSame("/{$root}/test2.txt", $responses[3]->getElementsByTagName('href')->item(0)->textContent);
$this->assertCount(0, $responses[3]->getElementsByTagName('resourcetype')->item(0)->childNodes);
$prop = $responses[3]->getElementsByTagName('prop')->item(0);
$this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('getlastmodified')->item(0)->textContent);
$this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('creationdate')->item(0)->textContent);
$this->assertSame('22', $prop->getElementsByTagName('getcontentlength')->item(0)->textContent);
$this->assertSame('text/html', $prop->getElementsByTagName('getcontenttype')->item(0)->textContent);
$this->assertCount(1, $prop->getElementsByTagName('getetag'));
$this->assertStringContainsString('200 OK', $responses[3]->getElementsByTagName('status')->item(0)->textContent);
// Test Depth:0
$response = $this->davRequest('PROPFIND', $root, '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john, ['Depth' => '0']);
$response->assertStatus(207);
$doc = $this->responseXML($response);
$this->assertSame('multistatus', $doc->documentElement->localName);
$this->assertCount(1, $responses = $doc->documentElement->getElementsByTagName('response'));
$this->assertSame("/{$root}/", $doc->getElementsByTagName('href')->item(0)->textContent);
// Test that Depth:infinity is not supported
// FIXME: Seems Sabre falls back to Depth:1 and does not respond with an error
$response = $this->davRequest('PROPFIND', $root, '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john, ['Depth' => 'infinity']);
$response->assertStatus(207);
$doc = $this->responseXML($response);
$this->assertSame('multistatus', $doc->documentElement->localName);
$this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
}
/**
* Test basic PROPFIND requests on non-root folder
*/
public function testPropfindOnCustomFolder(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
[$folders, $files] = $this->initTestStorage($john);
// Test with no Authorization header
$response = $this->davRequest('PROPFIND', "{$root}/folder1", '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>');
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test with valid Authorization header
$response = $this->davRequest('PROPFIND', "{$root}/folder1", '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
$response->assertStatus(207);
$doc = $this->responseXML($response);
$this->assertSame('multistatus', $doc->documentElement->localName);
$this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
$this->assertSame("/{$root}/folder1/", $responses[0]->getElementsByTagName('href')->item(0)->textContent);
$this->assertSame("/{$root}/folder1/folder2/", $responses[1]->getElementsByTagName('href')->item(0)->textContent);
$this->assertSame("/{$root}/folder1/test3.txt", $responses[2]->getElementsByTagName('href')->item(0)->textContent);
$this->assertSame("/{$root}/folder1/test4.txt", $responses[3]->getElementsByTagName('href')->item(0)->textContent);
}
/**
* Test basic PUT requests
*/
public function testPut(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Test with no Authorization header
$response = $this->davRequest('PUT', "{$root}/test.txt", 'Test', null, ['Content-Type' => 'text/plain']);
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test creating a file in the root folder (non-empty file)
$response = $this->davRequest('PUT', "{$root}/test.txt", 'Test', $john, ['Content-Type' => 'text/plain']);
$response->assertNoContent(201);
$response->assertHeader('ETag');
$this->assertCount(1, $files = $john->fsItems()->where('type', Item::TYPE_FILE)->get());
$this->assertSame('test.txt', $files[0]->getProperty('name'));
$this->assertSame('4', $files[0]->getProperty('size'));
$this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
$this->assertSame('Test', $this->getTestFileContent($files[0]));
// Test updating a file in the root folder (empty file)
$response = $this->davRequest('PUT', "{$root}/test.txt", '', $john, ['Content-Type' => 'text/plain']);
$response->assertNoContent(204);
$response->assertHeader('ETag');
$this->assertSame('test.txt', $files[0]->getProperty('name'));
$this->assertSame('0', $files[0]->getProperty('size'));
$this->assertSame('application/x-empty', $files[0]->getProperty('mimetype'));
$this->assertSame('', $this->getTestFileContent($files[0]));
// Test updating a file in the root folder (non-empty file)
$response = $this->davRequest('PUT', "{$root}/test.txt", 'Test222', $john, ['Content-Type' => 'text/plain']);
$response->assertNoContent(204);
$response->assertHeader('ETag');
$this->assertSame('test.txt', $files[0]->getProperty('name'));
$this->assertSame('7', $files[0]->getProperty('size'));
$this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
$this->assertSame('Test222', $this->getTestFileContent($files[0]));
$files[0]->delete();
$folder = $this->getTestCollection($john, 'folder1');
// Test creating a file in custom folder
$response = $this->davRequest('PUT', "{$root}/folder1/test.txt", '<html>aaa</html>', $john, ['Content-Type' => 'text/plain']);
$response->assertNoContent(201);
$response->assertHeader('ETag');
$this->assertCount(1, $files = $folder->children()->where('type', Item::TYPE_FILE)->get());
$this->assertSame('test.txt', $files[0]->getProperty('name'));
$this->assertSame('16', $files[0]->getProperty('size'));
$this->assertSame('text/html', $files[0]->getProperty('mimetype'));
$this->assertSame('<html>aaa</html>', $this->getTestFileContent($files[0]));
// Test updating a file in custom folder
$response = $this->davRequest('PUT', "{$root}/folder1/test.txt", 'Test', $john, ['Content-Type' => 'text/plain']);
$response->assertNoContent(204);
$response->assertHeader('ETag');
$this->assertSame('test.txt', $files[0]->getProperty('name'));
$this->assertSame('4', $files[0]->getProperty('size'));
$this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
$this->assertSame('Test', $this->getTestFileContent($files[0]));
}
/**
* Test basic PROPPATCH requests
*/
public function testProppatch(): void
{
$xml = <<<'EOF'
<d:propertyupdate xmlns:d="DAV:" xmlns:o="urn:schemas-microsoft-com:office:office">
<d:set>
<d:prop>
<o:Author>John Doe</o:Author>
</d:prop>
</d:set>
</d:propertyupdate>
EOF;
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Test with no Authorization header
$response = $this->davRequest('PROPPATCH', $root, $xml);
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test a PROPPATCH on the root
$response = $this->davRequest('PROPPATCH', $root, $xml, $john);
$response->assertStatus(207);
$doc = $this->responseXML($response);
$this->assertSame('multistatus', $doc->documentElement->localName);
$this->assertCount(1, $doc->getElementsByTagName('response'));
$this->assertSame('HTTP/1.1 403 Forbidden', $doc->getElementsByTagName('status')->item(0)->textContent);
// Note: We don't support any properties in PROPPATCH yet
}
/**
* Test basic REPORT requests
*/
public function testReport(): void
{
$xml = <<<'EOF'
<D:version-tree xmlns:D="DAV:">
<D:prop>
<D:version-name/>
<D:creator-displayname/>
<D:successor-set/>
</D:prop>
</D:version-tree>
EOF;
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
// Test with no Authorization header
$response = $this->davRequest('REPORT', $root, $xml);
$response->assertStatus(401);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
// Test a REPORT on the root, this report is not supported
$response = $this->davRequest('REPORT', $root, $xml, $john);
$response->assertStatus(415);
$doc = $this->responseXML($response);
$this->assertSame('error', $doc->documentElement->localName);
}
/**
* Test basic UNLOCK requests (RFC 4918)
*/
public function testUnlock(): void
{
$john = $this->getTestUser('john@kolab.org');
$root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
$file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
// Test with no Authorization header
$response = $this->davRequest('UNLOCK', "{$root}/test1.txt");
$response->assertNoContent(401);
// Test unlocking a file that is not locked
$response = $this->davRequest('UNLOCK', "{$root}/test1.txt", '', $john, ['Lock-Token' => "<opaquelocktoken:test>"]);
$response->assertStatus(409);
// TODO: Assert XML response
$lock = $file->locks()->create([
'token' => 'testtoken',
'depth' => 0,
'scope' => Lock::SCOPE_SHARED,
'timeout' => 1800,
'owner' => 'test',
]);
// Test unlocking a locked file (no Lock-Token header)
$response = $this->davRequest('UNLOCK', "{$root}/test1.txt", '', $john);
$response->assertNoContent(400);
// Test unlocking a locked file
$response = $this->davRequest('UNLOCK', "{$root}/test1.txt", '', $john, ['Lock-Token' => "<opaquelocktoken:{$lock->token}>"]);
$response->assertNoContent(204);
$this->assertCount(0, $file->locks()->get());
}
/**
* Do a HTTP request
*/
protected function davRequest($method, $url, $xml = '', $user = null, $headers = [])
{
if ($xml && empty($headers['Content-Type']) && !str_starts_with($xml, '<?xml')) {
$xml = '<?xml version="1.0" encoding="utf-8" ?>' . $xml;
}
$default_headers = [
'Content-Type' => 'text/xml; charset="utf-8"',
'Content-Length' => strlen($xml),
'Depth' => '1',
];
if ($user) {
$default_headers['Authorization'] = 'Basic ' . base64_encode($user->email . ':' . \config('app.passphrase'));
}
$server = $this->transformHeadersToServerVars($headers + $default_headers);
// When testing Context is not being reset, we have to do this manually
// https://github.com/laravel/framework/issues/57776
Context::flush();
return $this->call($method, $url, [], [], [], $server, $xml);
}
/**
* Parse the response XML
*/
protected function responseXML($response): \DOMDocument
{
$body = $response->baseResponse instanceof StreamedResponse ? $response->streamedContent() : $response->getContent();
if (empty($body)) {
$body = '<?xml version="1.0" encoding="utf-8" ?>';
}
// Remove space between tags for easier output handling
$body = preg_replace('/>[\s\t\r\n]+</', '><', $body);
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->loadXML($body);
$doc->formatOutput = true;
return $doc;
}
/**
* Initialize test folders/files in the storage
*/
protected function initTestStorage(User $user): array
{
/*
/test1.txt
/test2.txt
/folder1/
/folder1/folder2/
/folder1/test3.txt
/folder1/test4.txt
*/
$folders = $files = [];
$folders[] = $this->getTestCollection($user, 'folder1');
$folders[] = $this->getTestCollection($user, 'folder2');
$files[] = $this->getTestFile($user, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
$files[] = $this->getTestFile($user, 'test2.txt', '<html>Test con2</html>', ['mimetype' => 'text/html']);
$files[] = $this->getTestFile($user, 'test3.txt', 'Test content3', ['mimetype' => 'text/plain']);
$files[] = $this->getTestFile($user, 'test4.txt', '<p>Test con4</p>', ['mimetype' => 'text/html']);
$folders[0]->children()->attach($folders[1]);
$folders[0]->children()->attach($files[2]);
$folders[0]->children()->attach($files[3]);
return [$folders, $files];
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Apr 4, 6:59 AM (1 w, 4 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18744084
Default Alt Text
(47 KB)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline