mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
356 lines
14 KiB
PHP
356 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\Folder;
|
|
use App\Modules\Files\Models\FolderAssignment;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
$this->token = $this->admin->createToken('t', [
|
|
Permission::Upload->value,
|
|
Permission::EditFiles->value,
|
|
Permission::EditOthersFiles->value,
|
|
Permission::DeleteFiles->value,
|
|
Permission::DeleteOthersFiles->value,
|
|
Permission::CreateOwnFolders->value,
|
|
Permission::UploadPublic->value,
|
|
])->plainTextToken;
|
|
});
|
|
|
|
/** A token for a staff member whose role holds exactly these permissions. */
|
|
function folderApiToken(array $permissions): string
|
|
{
|
|
$user = staffWithPermissions(array_map(fn (Permission $p): string => $p->value, $permissions));
|
|
|
|
return $user->createToken('t', array_map(fn (Permission $p): string => $p->value, $permissions))->plainTextToken;
|
|
}
|
|
|
|
/** A client manager scoped to one client, and that client. */
|
|
function scopedFolderManager(): array
|
|
{
|
|
$client = User::factory()->client()->create();
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$manager->assignedClients()->sync([$client->id]);
|
|
|
|
return [$manager, $client];
|
|
}
|
|
|
|
test('folders list with their place in the tree', function () {
|
|
$clients = makeFolder('Clients');
|
|
$acme = makeFolder('Acme', $clients);
|
|
$year = makeFolder('2026', $acme);
|
|
|
|
$rows = collect($this->withToken($this->token)->getJson('/api/v1/folders')->assertOk()->json('data'))->keyBy('id');
|
|
|
|
expect($rows[$year->id]['parent_id'])->toBe($acme->id)
|
|
->and($rows[$year->id]['path'])->toBe('Clients / Acme / 2026')
|
|
->and($rows[$year->id]['ancestors'])->toBe([
|
|
['id' => $clients->id, 'name' => 'Clients'],
|
|
['id' => $acme->id, 'name' => 'Acme'],
|
|
])
|
|
->and($rows[$clients->id]['ancestors'])->toBe([])
|
|
->and($rows[$clients->id]['path'])->toBe('Clients');
|
|
});
|
|
|
|
test('filters narrow the listing', function () {
|
|
$top = makeFolder('Projects');
|
|
$child = makeFolder('Invoices', $top);
|
|
$other = makeFolder('Archive');
|
|
|
|
$ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/folders?{$query}")->assertOk()->json('data.*.id');
|
|
|
|
expect($ids("parent_id={$top->id}"))->toBe([$child->id])
|
|
->and($ids('top_level=1'))->toEqualCanonicalizing([$top->id, $other->id])
|
|
->and($ids('search=voice'))->toBe([$child->id]);
|
|
});
|
|
|
|
test('polling with updated_since returns what changed, oldest first', function () {
|
|
$this->travelTo(now()->subDay());
|
|
$old = makeFolder('Old');
|
|
$this->travelBack();
|
|
|
|
$since = now()->subMinute()->toIso8601String();
|
|
$new = makeFolder('New');
|
|
|
|
$ids = $this->withToken($this->token)
|
|
->getJson('/api/v1/folders?updated_since='.urlencode($since))
|
|
->assertOk()->json('data.*.id');
|
|
|
|
expect($ids)->toBe([$new->id])->not->toContain($old->id);
|
|
});
|
|
|
|
test('a token without a file ability cannot list folders', function () {
|
|
$token = folderApiToken([Permission::ViewNews]);
|
|
|
|
$this->withToken($token)->getJson('/api/v1/folders')->assertForbidden();
|
|
});
|
|
|
|
/*
|
|
* The listing is the library screen's own scope, and the trail above a
|
|
* folder must not name folders the caller cannot reach.
|
|
*/
|
|
test('a client-scoped token sees only its folders, and not the names above them', function () {
|
|
[$manager, $client] = scopedFolderManager();
|
|
|
|
$secret = makeFolder('Board minutes');
|
|
$shared = makeFolder('Acme', $secret);
|
|
$unrelated = makeFolder('Somebody else');
|
|
$this->actingAs($this->admin)->post("/folders/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
|
|
$token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken;
|
|
|
|
$rows = collect($this->withToken($token)->getJson('/api/v1/folders')->assertOk()->json('data'))->keyBy('id');
|
|
|
|
expect($rows->keys()->all())->toContain($shared->id)
|
|
->not->toContain($unrelated->id)
|
|
->not->toContain($secret->id)
|
|
->and($rows[$shared->id]['ancestors'])->toBe([])
|
|
->and($rows[$shared->id]['path'])->toBe('Acme');
|
|
|
|
$this->withToken($token)->getJson("/api/v1/folders/{$unrelated->id}")->assertForbidden();
|
|
$this->withToken($token)->getJson("/api/v1/folders/{$shared->id}")->assertOk()->assertJsonPath('data.path', 'Acme');
|
|
});
|
|
|
|
test('an unscoped token sees the whole trail of the same folder', function () {
|
|
$secret = makeFolder('Board minutes');
|
|
$shared = makeFolder('Acme', $secret);
|
|
|
|
$this->withToken($this->token)->getJson("/api/v1/folders/{$shared->id}")
|
|
->assertOk()
|
|
->assertJsonPath('data.path', 'Board minutes / Acme');
|
|
});
|
|
|
|
test('a folder can be created at the top or inside another', function () {
|
|
$response = $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Clients'])
|
|
->assertStatus(201)
|
|
->assertJsonPath('data.name', 'Clients')
|
|
->assertJsonPath('data.parent_id', null);
|
|
|
|
$parentId = $response->json('data.id');
|
|
|
|
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme', 'parent_id' => $parentId])
|
|
->assertStatus(201)
|
|
->assertJsonPath('data.parent_id', $parentId)
|
|
->assertJsonPath('data.path', 'Clients / Acme');
|
|
|
|
$folder = Folder::query()->where('name', 'Acme')->firstOrFail();
|
|
expect($folder->created_by)->toBe($this->admin->id)
|
|
->and(ActivityLog::query()->where('action', Action::FolderCreated)->count())->toBe(2);
|
|
});
|
|
|
|
test('creating a folder that already exists returns it instead of a second one', function () {
|
|
$parent = makeFolder('Clients');
|
|
$existing = makeFolder('Acme', $parent);
|
|
|
|
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme', 'parent_id' => $parent->id])
|
|
->assertStatus(200)
|
|
->assertJsonPath('data.id', $existing->id);
|
|
|
|
// Same name somewhere else is a different folder.
|
|
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme'])->assertStatus(201);
|
|
|
|
expect(Folder::query()->where('name', 'Acme')->count())->toBe(2);
|
|
});
|
|
|
|
test('creating needs upload as well as create_own_folders', function () {
|
|
$token = folderApiToken([Permission::CreateOwnFolders]);
|
|
|
|
$this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Nope'])->assertForbidden();
|
|
|
|
expect(Folder::query()->where('name', 'Nope')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a folder cannot be created inside a public folder without upload_public', function () {
|
|
$public = makeFolder('Press kit');
|
|
$public->update(['public' => true]);
|
|
|
|
$token = folderApiToken([Permission::CreateOwnFolders, Permission::Upload, Permission::EditOthersFiles]);
|
|
|
|
$this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertForbidden();
|
|
|
|
expect(Folder::query()->where('name', 'Drafts')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a client-scoped token cannot create inside a folder it cannot see', function () {
|
|
[$manager] = scopedFolderManager();
|
|
$hidden = makeFolder('Somebody else');
|
|
|
|
$token = $manager->createToken('t', [Permission::CreateOwnFolders->value, Permission::Upload->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Sneaky', 'parent_id' => $hidden->id])->assertNotFound();
|
|
|
|
expect(Folder::query()->where('name', 'Sneaky')->exists())->toBeFalse();
|
|
});
|
|
|
|
test('the depth cap applies', function () {
|
|
$parent = null;
|
|
|
|
// The deepest folder allowed: one more level is refused.
|
|
for ($i = 0; $i < Folder::MAX_DEPTH; $i++) {
|
|
$parent = makeFolder("Level {$i}", $parent);
|
|
}
|
|
|
|
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Too deep', 'parent_id' => $parent?->id])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors('parent_id');
|
|
});
|
|
|
|
test('a folder can be renamed and moved, carrying its subtree', function () {
|
|
$from = makeFolder('From');
|
|
$to = makeFolder('To');
|
|
$folder = makeFolder('Acme', $from);
|
|
$child = makeFolder('2026', $folder);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['name' => 'Acme Inc', 'parent_id' => $to->id])
|
|
->assertOk()
|
|
->assertJsonPath('data.name', 'Acme Inc')
|
|
->assertJsonPath('data.parent_id', $to->id)
|
|
->assertJsonPath('data.path', 'To / Acme Inc');
|
|
|
|
$this->withToken($this->token)->getJson("/api/v1/folders/{$child->id}")
|
|
->assertJsonPath('data.path', 'To / Acme Inc / 2026');
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => null])
|
|
->assertOk()
|
|
->assertJsonPath('data.parent_id', null);
|
|
|
|
expect(ActivityLog::query()->where('action', Action::FolderRenamed)->count())->toBe(1)
|
|
->and(ActivityLog::query()->where('action', Action::FolderMoved)->count())->toBe(2);
|
|
});
|
|
|
|
test('only the fields sent change', function () {
|
|
$parent = makeFolder('Parent');
|
|
$folder = makeFolder('Acme', $parent);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['name' => 'Renamed'])
|
|
->assertOk()
|
|
->assertJsonPath('data.parent_id', $parent->id);
|
|
});
|
|
|
|
test('a folder cannot be moved into itself or below itself', function () {
|
|
$folder = makeFolder('Acme');
|
|
$child = makeFolder('2026', $folder);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => $child->id])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors('parent_id');
|
|
});
|
|
|
|
test('a folder cannot be moved into a public folder without upload_public', function () {
|
|
$public = makeFolder('Press kit');
|
|
$public->update(['public' => true]);
|
|
$folder = makeFolder('Private drafts');
|
|
|
|
$token = folderApiToken([Permission::Upload, Permission::EditFiles, Permission::EditOthersFiles]);
|
|
|
|
$this->withToken($token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => $public->id])->assertForbidden();
|
|
|
|
expect($folder->fresh()?->parent_id)->toBeNull();
|
|
});
|
|
|
|
test('an empty folder is deleted', function () {
|
|
$folder = makeFolder('Empty');
|
|
|
|
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")->assertNoContent();
|
|
|
|
expect(Folder::query()->whereKey($folder->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a folder with content is refused unless the cascade is asked for', function () {
|
|
$folder = makeFolder('Acme');
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]);
|
|
|
|
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")
|
|
->assertStatus(409)
|
|
->assertJsonPath('type', 'conflict');
|
|
|
|
expect(Folder::query()->whereKey($folder->id)->exists())->toBeTrue()
|
|
->and(File::query()->whereKey($file->id)->exists())->toBeTrue();
|
|
|
|
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}", ['content_action' => 'cascade_delete'])
|
|
->assertNoContent();
|
|
|
|
expect(Folder::query()->whereKey($folder->id)->exists())->toBeFalse()
|
|
->and(File::query()->whereKey($file->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a folder holding only a subfolder counts as not empty', function () {
|
|
$folder = makeFolder('Acme');
|
|
makeFolder('2026', $folder);
|
|
|
|
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")->assertStatus(409);
|
|
});
|
|
|
|
test('the cascade is refused when it would take a file the token may not delete', function () {
|
|
$staff = staffWithPermissions([
|
|
Permission::Upload->value, Permission::EditFiles->value,
|
|
Permission::DeleteFiles->value, Permission::CreateOwnFolders->value,
|
|
]);
|
|
$token = $staff->createToken('t', [Permission::DeleteFiles->value])->plainTextToken;
|
|
|
|
$folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]);
|
|
$foreign = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]);
|
|
|
|
$this->withToken($token)->deleteJson("/api/v1/folders/{$folder->id}", ['content_action' => 'cascade_delete'])
|
|
->assertForbidden();
|
|
|
|
expect(Folder::query()->whereKey($folder->id)->exists())->toBeTrue()
|
|
->and(File::query()->whereKey($foreign->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a folder can be shared with a client and unshared', function () {
|
|
$folder = makeFolder('Acme');
|
|
$client = User::factory()->client()->create();
|
|
|
|
$this->withToken($this->token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
|
->assertOk()
|
|
->assertJsonPath('data.assignments.0.type', 'client')
|
|
->assertJsonPath('data.assignments.0.id', $client->id);
|
|
|
|
// Again: still one share.
|
|
$this->withToken($this->token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
|
->assertOk();
|
|
|
|
expect(FolderAssignment::query()->where('folder_id', $folder->id)->count())->toBe(1)
|
|
->and(ActivityLog::query()->where('action', Action::FolderShared)->exists())->toBeTrue();
|
|
|
|
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
|
->assertOk()
|
|
->assertJsonPath('data.assignments', []);
|
|
|
|
expect(FolderAssignment::query()->where('folder_id', $folder->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a client-scoped token cannot share with somebody else\'s client', function () {
|
|
[$manager] = scopedFolderManager();
|
|
$stranger = User::factory()->client()->create();
|
|
$folder = Folder::query()->create(['name' => 'Mine', 'created_by' => $manager->id]);
|
|
|
|
$token = $manager->createToken('t', [Permission::EditFiles->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $stranger->id])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors('id');
|
|
|
|
expect(FolderAssignment::query()->where('folder_id', $folder->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('a file reports its folder\'s parent', function () {
|
|
$parent = makeFolder('Clients');
|
|
$folder = makeFolder('Acme', $parent);
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]);
|
|
|
|
$this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")
|
|
->assertOk()
|
|
->assertJsonPath('data.folder.parent_id', $parent->id);
|
|
});
|