mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
5f414c7a4a
The staff library grew filters for uploader, role, public/private, download count and version. Two of those already existed on /api/v1/files (`uploaded_by`, `public`); the other four did not, so an integration could not ask what the screen asks. Adds `role_id`, `downloads=none|any`, `version=current|outdated` and `visibility=public|private`. `visibility` rather than changing `public`, and that is the decision worth explaining. `public` has always tested the file's own column, and callers depend on that answer; changing what an existing filter means is breaking for everyone already sending it, however much better the new meaning is. So `public` is untouched and `visibility` is added beside it with the application's own definition -- File::isEffectivelyPublic(), the flag or a public folder anywhere above the file -- which is what the badge on a staff row means. The guide says in a sentence which to reach for. Point the visibility filter at the column instead and the test that separates them fails, which is the whole point of having both. That predicate now lives once, as File::scopeEffectivelyPublic(), beside the isEffectivelyPublic() it has to agree with. It was a private helper on FoldersController until a second surface wanted it. `role_id` deliberately carries no identity guard, unlike `uploaded_by` beside it. A role names nobody: the files in the result are ones the caller may already read, and learning one came from somebody holding the Client role narrows to a set they could have guessed. `uploaded_by` is different in kind -- a non-empty answer confirms exactly the identity the response is redacting -- which is why only it is guarded. The reasoning is in the code, because an absent guard sitting next to a present one is the kind of thing a reader should not have to re-derive. Tests cover each filter, the public/visibility split, and the client-scoped negative: every new filter still returns nothing outside the token's own library, because a filter narrows a library and never widens one.
277 lines
12 KiB
PHP
277 lines
12 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\Folders\FolderService;
|
|
use App\Modules\Files\Models\Category;
|
|
use App\Modules\Files\Models\File;
|
|
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,
|
|
])->plainTextToken;
|
|
});
|
|
|
|
test('the listing returns files with an explicit field set', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Quarterly report']);
|
|
|
|
$response = $this->withToken($this->token)->getJson('/api/v1/files')->assertOk();
|
|
|
|
$response->assertJsonPath('data.0.id', $file->id)
|
|
->assertJsonPath('data.0.name', 'Quarterly report')
|
|
->assertJsonPath('data.0.mime_type', $file->mime_type);
|
|
|
|
// Storage layout must never leave the server: a caller downloads
|
|
// through the download endpoint, and publishing where the bytes sit is
|
|
// the map you would want in order to reach them another way.
|
|
$body = $response->json('data.0');
|
|
expect($body)->not->toHaveKey('path')
|
|
->and($body)->not->toHaveKey('disk');
|
|
});
|
|
|
|
test('no response ever carries credential columns', function () {
|
|
File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$bodies = [
|
|
$this->withToken($this->token)->getJson('/api/v1/files')->getContent(),
|
|
$this->withToken($this->token)->getJson('/api/v1/me')->getContent(),
|
|
];
|
|
|
|
foreach ($bodies as $body) {
|
|
expect($body)->not->toContain('password')
|
|
->and($body)->not->toContain('two_factor_secret')
|
|
->and($body)->not->toContain('two_factor_recovery_codes')
|
|
->and($body)->not->toContain('remember_token');
|
|
}
|
|
});
|
|
|
|
test('a token without any view ability cannot list files', function () {
|
|
$limited = staffWithPermissions([Permission::ViewNews->value]);
|
|
$token = $limited->createToken('t', [Permission::ViewNews->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->getJson('/api/v1/files')->assertForbidden();
|
|
});
|
|
|
|
/*
|
|
* The listing must be built from ViewableFileScope, never File::query().
|
|
* A client-scoped staff member sees their own uploads plus their assigned
|
|
* clients' files — and nothing else — exactly as in the UI.
|
|
*/
|
|
test('a client-scoped staff token sees only its own scope', function () {
|
|
$client = User::factory()->client()->create();
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$manager->assignedClients()->sync([$client->id]);
|
|
|
|
$own = File::factory()->create(['uploaded_by' => $manager->id, 'name' => 'mine']);
|
|
$unrelated = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'not mine']);
|
|
|
|
$shared = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'shared with my client']);
|
|
$this->actingAs($this->admin)->post("/files/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
|
|
$token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken;
|
|
|
|
$ids = $this->withToken($token)->getJson('/api/v1/files')->assertOk()->json('data.*.id');
|
|
|
|
expect($ids)->toContain($own->id)
|
|
->and($ids)->toContain($shared->id)
|
|
->and($ids)->not->toContain($unrelated->id);
|
|
|
|
// And direct access respects the same boundary.
|
|
$this->withToken($token)->getJson("/api/v1/files/{$unrelated->id}")->assertForbidden();
|
|
});
|
|
|
|
test('filters narrow the listing', function () {
|
|
$category = Category::query()->create(['name' => 'Invoices']);
|
|
$other = User::factory()->create();
|
|
|
|
$match = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'invoice march']);
|
|
$match->categories()->attach($category->id);
|
|
|
|
$byOther = File::factory()->create(['uploaded_by' => $other->id, 'name' => 'someone elses']);
|
|
$expired = File::factory()->create(['uploaded_by' => $this->admin->id, 'expires_at' => now()->subDay()]);
|
|
|
|
$ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id');
|
|
|
|
expect($ids('search=invoice'))->toBe([$match->id])
|
|
->and($ids("category_id={$category->id}"))->toBe([$match->id])
|
|
->and($ids("uploaded_by={$other->id}"))->toBe([$byOther->id])
|
|
->and($ids('expired=1'))->toBe([$expired->id])
|
|
->and($ids('expired=0'))->not->toContain($expired->id);
|
|
});
|
|
|
|
test('the new filters narrow the listing the same way the staff library does', function () {
|
|
$uploader = User::factory()->role(SystemRole::Uploader)->create();
|
|
|
|
$grabbed = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'grabbed']);
|
|
$untouched = File::factory()->create(['uploaded_by' => $uploader->id, 'name' => 'untouched']);
|
|
$superseded = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'draft one']);
|
|
$current = File::factory()->create([
|
|
'uploaded_by' => $this->admin->id,
|
|
'name' => 'draft two',
|
|
'previous_file_id' => $superseded->id,
|
|
'version_root_id' => $superseded->id,
|
|
]);
|
|
|
|
ActivityLog::query()->create([
|
|
'actor_id' => $this->admin->id,
|
|
'action' => Action::FileDownloaded,
|
|
'subject_type' => $grabbed->getMorphClass(),
|
|
'subject_id' => $grabbed->id,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id');
|
|
|
|
expect($ids('downloads=any'))->toBe([$grabbed->id])
|
|
->and($ids('downloads=none'))->not->toContain($grabbed->id)
|
|
->and($ids('downloads=none'))->toContain($untouched->id)
|
|
->and($ids('version=outdated'))->toBe([$superseded->id])
|
|
->and($ids('version=current'))->toContain($current->id)
|
|
->and($ids('version=current'))->not->toContain($superseded->id)
|
|
->and($ids("role_id={$uploader->role_id}"))->toBe([$untouched->id]);
|
|
});
|
|
|
|
test('visibility asks the effective question and public still asks the column', function () {
|
|
$folder = app(FolderService::class)->create('Brochures', null);
|
|
$folder->update(['public' => true]);
|
|
|
|
$flagged = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'flagged', 'public' => true]);
|
|
$inherited = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'inherited', 'public' => false, 'folder_id' => $folder->id]);
|
|
$private = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'plain', 'public' => false]);
|
|
|
|
$ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id');
|
|
|
|
// The whole reason both exist. `public` reads the column, so the file
|
|
// that is public only by inheritance is absent -- and callers already
|
|
// depend on that answer, which is why its meaning was left alone.
|
|
expect($ids('public=1'))->toBe([$flagged->id])
|
|
->and($ids('public=1'))->not->toContain($inherited->id);
|
|
|
|
// `visibility` reads File::isEffectivelyPublic(), which is what the
|
|
// badge on the staff row means, so the inherited one counts.
|
|
expect($ids('visibility=public'))->toContain($flagged->id)
|
|
->and($ids('visibility=public'))->toContain($inherited->id)
|
|
->and($ids('visibility=private'))->toBe([$private->id]);
|
|
});
|
|
|
|
test('a client-scoped token cannot use the new filters to reach past its own library', function () {
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken;
|
|
|
|
// Nothing here is the manager's: a file they did not upload, for a
|
|
// client they do not hold. Every filter must still come back empty --
|
|
// a filter narrows a library, it never widens one.
|
|
$stranger = User::factory()->create();
|
|
File::factory()->create(['uploaded_by' => $stranger->id, 'name' => 'not theirs', 'public' => true]);
|
|
|
|
foreach (['downloads=none', 'version=current', 'visibility=public', 'visibility=private', "role_id={$stranger->role_id}"] as $query) {
|
|
expect($this->withToken($token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data'))
|
|
->toBe([], "filter '{$query}' leaked past the client-scoped boundary");
|
|
}
|
|
});
|
|
|
|
test('a malformed updated_since is rejected rather than ignored', function () {
|
|
// Silently ignoring it would make a polling client re-read the whole
|
|
// library every tick and never find out why.
|
|
$this->withToken($this->token)->getJson('/api/v1/files?updated_since=not-a-date')
|
|
->assertStatus(422)
|
|
->assertJsonPath('type', 'validation_failed');
|
|
});
|
|
|
|
test('per_page is capped', function () {
|
|
File::factory()->count(3)->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$max = (int) config('api.pagination.max_per_page');
|
|
|
|
$this->withToken($this->token)->getJson('/api/v1/files?per_page='.($max + 1))
|
|
->assertStatus(422);
|
|
});
|
|
|
|
/*
|
|
* The polling contract: walking with updated_since + cursor must visit
|
|
* every row exactly once, including rows created or touched mid-walk.
|
|
*/
|
|
test('the updated_since walk returns every file exactly once', function () {
|
|
$since = now()->subMinute();
|
|
|
|
foreach (range(1, 7) as $i) {
|
|
File::factory()->create([
|
|
'uploaded_by' => $this->admin->id,
|
|
'name' => "file {$i}",
|
|
'updated_at' => now()->addSeconds($i),
|
|
]);
|
|
}
|
|
|
|
$seen = [];
|
|
$url = '/api/v1/files?per_page=2&updated_since='.urlencode($since->toIso8601String());
|
|
|
|
for ($page = 0; $page < 10 && $url !== null; $page++) {
|
|
$body = $this->withToken($this->token)->getJson($url)->assertOk()->json();
|
|
$seen = array_merge($seen, array_column($body['data'], 'id'));
|
|
$url = $body['links']['next'] ?? null;
|
|
}
|
|
|
|
expect($seen)->toHaveCount(7)
|
|
->and(array_unique($seen))->toHaveCount(7)
|
|
// Ascending by updated_at, so a caller can take the last value as
|
|
// the next poll's watermark.
|
|
->and($seen)->toBe(File::query()->orderBy('updated_at')->orderBy('id')->pluck('id')->all());
|
|
});
|
|
|
|
test('updated_since excludes files older than the watermark', function () {
|
|
$old = File::factory()->create(['uploaded_by' => $this->admin->id, 'updated_at' => now()->subDays(2)]);
|
|
$fresh = File::factory()->create(['uploaded_by' => $this->admin->id, 'updated_at' => now()]);
|
|
|
|
$ids = $this->withToken($this->token)
|
|
->getJson('/api/v1/files?updated_since='.urlencode(now()->subHour()->toIso8601String()))
|
|
->assertOk()->json('data.*.id');
|
|
|
|
expect($ids)->toBe([$fresh->id])
|
|
->and($ids)->not->toContain($old->id);
|
|
});
|
|
|
|
test('show includes the relations an integration needs', function () {
|
|
$client = User::factory()->client()->create(['name' => 'Acme']);
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
|
|
$this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")
|
|
->assertOk()
|
|
->assertJsonPath('data.id', $file->id)
|
|
->assertJsonPath('data.uploaded_by.name', $this->admin->name)
|
|
->assertJsonPath('data.assignments.0.type', 'client')
|
|
->assertJsonPath('data.assignments.0.name', 'Acme');
|
|
});
|
|
|
|
test('downloading over the API authorizes and audits like the web route', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($this->token)->get("/api/v1/files/{$file->id}/download")
|
|
->assertOk()
|
|
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
|
|
|
expect(ActivityLog::query()
|
|
->where('action', Action::FileDownloaded)
|
|
->where('subject_id', $file->id)
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a token cannot download a file outside its scope', function () {
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken;
|
|
|
|
$unrelated = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($token)->getJson("/api/v1/files/{$unrelated->id}/download")->assertForbidden();
|
|
});
|