mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
e1cd010f9d
FilesController::expiryInstant exists because a calendar day ends where the person naming it lives: the web form posts a bare YYYY-MM-DD, and storing that as it arrives would cut a file off at midnight UTC -- "expires on the 12th" ending partway through the 11th for anyone in the Americas. The API takes the same field, validates it as a date, and stores it raw: web → 2026-09-12T23:59:59+00:00 (end of the day, as the docblock means) API → 2026-09-12T00:00:00+00:00 (raw) Same value, same field, same file, two meanings -- and the earlier of the two is a file that dies at the start of the day it was promised. A bare date now means the end of that day in the caller's timezone, as it does on the web. A value carrying a time is unchanged: it is an instant the caller named on purpose, the API can express one and a date input cannot. The endpoint's docblock says both, so the OpenAPI document does too. Three tests: the day, the timestamp, and clearing. Without the fix the first goes red.
313 lines
13 KiB
PHP
313 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Audit\ActivityOrigin;
|
|
use App\Modules\Files\Models\Category;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\FileAssignment;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Identity\Permissions\SystemRole;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Http\UploadedFile;
|
|
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::SetFileExpirationDate->value,
|
|
Permission::SetFileCategories->value,
|
|
Permission::UploadPublic->value,
|
|
])->plainTextToken;
|
|
});
|
|
|
|
test('a file can be uploaded in a single request', function () {
|
|
$response = $this->withToken($this->token)->post('/api/v1/files', [
|
|
'file' => UploadedFile::fake()->create('report.pdf', 12, 'application/pdf'),
|
|
'name' => 'Quarterly report',
|
|
'description' => 'Q3',
|
|
], ['Accept' => 'application/json']);
|
|
|
|
$response->assertStatus(201)
|
|
->assertJsonPath('data.name', 'Quarterly report')
|
|
->assertJsonPath('data.original_name', 'report.pdf');
|
|
|
|
$file = File::query()->latest('id')->firstOrFail();
|
|
expect($file->uploaded_by)->toBe($this->admin->id)
|
|
->and(Storage::disk('files')->exists($file->path))->toBeTrue();
|
|
});
|
|
|
|
test('the upload honours the max file size setting, not a hardcoded cap', function () {
|
|
// Snapshot and restore: never assume a Setting's current value.
|
|
$settings = app(Settings::class);
|
|
$original = $settings->get(Setting::MaxFileSizeMb);
|
|
|
|
try {
|
|
$settings->set(Setting::MaxFileSizeMb, 1);
|
|
|
|
$this->withToken($this->token)->post('/api/v1/files', [
|
|
'file' => UploadedFile::fake()->create('big.pdf', 2048, 'application/pdf'),
|
|
], ['Accept' => 'application/json'])
|
|
->assertStatus(422)
|
|
->assertJsonPath('type', 'validation_failed');
|
|
|
|
expect(File::query()->count())->toBe(0);
|
|
} finally {
|
|
$settings->set(Setting::MaxFileSizeMb, $original);
|
|
}
|
|
});
|
|
|
|
test('a disallowed extension is refused', function () {
|
|
$settings = app(Settings::class);
|
|
$originalRestriction = $settings->get(Setting::UploadTypeRestriction);
|
|
$originalExtensions = $settings->get(Setting::AllowedUploadExtensions);
|
|
|
|
try {
|
|
$settings->set(Setting::UploadTypeRestriction, 'everyone');
|
|
$settings->set(Setting::AllowedUploadExtensions, ['pdf']);
|
|
|
|
$this->withToken($this->token)->post('/api/v1/files', [
|
|
'file' => UploadedFile::fake()->create('script.sh', 1, 'text/plain'),
|
|
], ['Accept' => 'application/json'])->assertStatus(422);
|
|
|
|
expect(File::query()->count())->toBe(0);
|
|
} finally {
|
|
$settings->set(Setting::UploadTypeRestriction, $originalRestriction);
|
|
$settings->set(Setting::AllowedUploadExtensions, $originalExtensions);
|
|
}
|
|
});
|
|
|
|
test('the stored mime type comes from the bytes, not the request', function () {
|
|
// A caller controls the Content-Type it declares, and the mime type
|
|
// decides how the file is later served and previewed.
|
|
$this->withToken($this->token)->post('/api/v1/files', [
|
|
'file' => UploadedFile::fake()->createWithContent('note.txt', 'plain text here'),
|
|
], ['Accept' => 'application/json'])->assertStatus(201);
|
|
|
|
expect(File::query()->latest('id')->firstOrFail()->mime_type)->not->toBe('application/x-httpd-php');
|
|
});
|
|
|
|
test('uploading requires the upload ability specifically', function () {
|
|
$token = $this->admin->createToken('read-only', [Permission::EditFiles->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->post('/api/v1/files', [
|
|
'file' => UploadedFile::fake()->create('x.pdf', 1, 'application/pdf'),
|
|
], ['Accept' => 'application/json'])->assertForbidden();
|
|
});
|
|
|
|
test('a file can be updated field by field', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Old name']);
|
|
$category = Category::query()->create(['name' => 'Invoices']);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
|
'name' => 'New name',
|
|
'categories' => [$category->id],
|
|
'expires_at' => now()->addWeek()->toIso8601String(),
|
|
])->assertOk()->assertJsonPath('data.name', 'New name');
|
|
|
|
$file->refresh();
|
|
expect($file->name)->toBe('New name')
|
|
->and($file->expires_at)->not->toBeNull()
|
|
->and($file->categories()->count())->toBe(1)
|
|
// Untouched: PATCH changes only what was sent.
|
|
->and($file->description)->toBe($file->getOriginal('description'));
|
|
});
|
|
|
|
test('a calendar day means the end of that day where the caller lives', function () {
|
|
// The same value on the web means the end of the 12th (LocalDay::end via
|
|
// FilesController::expiryInstant). Stored as it arrives it is midnight
|
|
// UTC, so the file would die at the *start* of the 12th instead.
|
|
$this->admin->update(['timezone' => 'Europe/Berlin']);
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
|
'expires_at' => '2026-09-12',
|
|
])->assertOk();
|
|
|
|
// 23:59:59 on the 12th in Berlin is 21:59:59Z.
|
|
expect($file->refresh()->expires_at?->toIso8601String())->toBe('2026-09-12T21:59:59+00:00');
|
|
});
|
|
|
|
test('a timestamp is stored as the instant it names', function () {
|
|
// The half that must not change: an API caller can name a moment, and
|
|
// naming one is not the same as naming a day.
|
|
$this->admin->update(['timezone' => 'Europe/Berlin']);
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
|
'expires_at' => '2026-09-12T08:30:00+00:00',
|
|
])->assertOk();
|
|
|
|
expect($file->refresh()->expires_at?->toIso8601String())->toBe('2026-09-12T08:30:00+00:00');
|
|
});
|
|
|
|
test('clearing the expiry still clears it', function () {
|
|
$file = File::factory()->create([
|
|
'uploaded_by' => $this->admin->id,
|
|
'expires_at' => now()->addWeek(),
|
|
]);
|
|
|
|
$this->withToken($this->token)->patchJson("/api/v1/files/{$file->id}", [
|
|
'expires_at' => null,
|
|
])->assertOk();
|
|
|
|
expect($file->refresh()->expires_at)->toBeNull();
|
|
});
|
|
|
|
test('fields the caller lacks permission for are left alone rather than refused', function () {
|
|
// Mirrors the web controller: a user who may edit a file but not set
|
|
// expiry dates still gets to rename it.
|
|
$editor = staffWithPermissions([Permission::EditFiles->value]);
|
|
$file = File::factory()->create(['uploaded_by' => $editor->id, 'name' => 'Original']);
|
|
$token = $editor->createToken('t', [Permission::EditFiles->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->patchJson("/api/v1/files/{$file->id}", [
|
|
'name' => 'Renamed',
|
|
'expires_at' => now()->addWeek()->toIso8601String(),
|
|
// A slug is required alongside public=true by Rules::slug, the same
|
|
// as on the web form — supplied here so the request is valid and the
|
|
// permission gate is what decides the outcome, not validation.
|
|
'public' => true,
|
|
'slug' => 'renamed-file',
|
|
])->assertOk();
|
|
|
|
$file->refresh();
|
|
expect($file->name)->toBe('Renamed')
|
|
->and($file->expires_at)->toBeNull()
|
|
->and($file->public)->toBeFalse();
|
|
});
|
|
|
|
test('editing someone elses file needs the others permission', function () {
|
|
$other = User::factory()->create();
|
|
$file = File::factory()->create(['uploaded_by' => $other->id]);
|
|
|
|
$ownOnly = staffWithPermissions([Permission::EditFiles->value]);
|
|
$token = $ownOnly->createToken('t', [Permission::EditFiles->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->patchJson("/api/v1/files/{$file->id}", ['name' => 'Mine now'])
|
|
->assertForbidden();
|
|
});
|
|
|
|
test('a file can be deleted', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($this->token)->deleteJson("/api/v1/files/{$file->id}")->assertNoContent();
|
|
|
|
expect(File::query()->find($file->id))->toBeNull()
|
|
->and(ActivityLog::query()->where('action', Action::FileDeleted)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('deleting needs a delete ability, not merely edit', function () {
|
|
$editor = staffWithPermissions([Permission::EditFiles->value]);
|
|
$file = File::factory()->create(['uploaded_by' => $editor->id]);
|
|
$token = $editor->createToken('t', [Permission::EditFiles->value])->plainTextToken;
|
|
|
|
$this->withToken($token)->deleteJson("/api/v1/files/{$file->id}")->assertForbidden();
|
|
|
|
expect(File::query()->find($file->id))->not->toBeNull();
|
|
});
|
|
|
|
test('a file can be assigned to a client and to a group', function () {
|
|
$client = User::factory()->client()->create(['name' => 'Acme']);
|
|
$group = Group::query()->create(['name' => 'Partners']);
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($this->token)
|
|
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
|
->assertOk();
|
|
|
|
$this->withToken($this->token)
|
|
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'group', 'id' => $group->id])
|
|
->assertOk();
|
|
|
|
expect(FileAssignment::query()->where('file_id', $file->id)->count())->toBe(2)
|
|
->and(ActivityLog::query()->where('action', Action::FileAssigned)->count())->toBe(2);
|
|
});
|
|
|
|
test('assigning twice is idempotent, so a retry is safe', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
foreach (range(1, 3) as $ignored) {
|
|
$this->withToken($this->token)
|
|
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
|
->assertOk();
|
|
}
|
|
|
|
expect(FileAssignment::query()->where('file_id', $file->id)->count())->toBe(1);
|
|
});
|
|
|
|
test('an assignment can be revoked', function () {
|
|
$client = User::factory()->client()->create();
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->withToken($this->token)
|
|
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
|
|
|
|
$this->withToken($this->token)
|
|
->deleteJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id])
|
|
->assertOk();
|
|
|
|
expect(FileAssignment::query()->where('file_id', $file->id)->count())->toBe(0);
|
|
});
|
|
|
|
test('a client-scoped staff token cannot share with someone elses client', function () {
|
|
$mine = User::factory()->client()->create();
|
|
$theirs = User::factory()->client()->create();
|
|
|
|
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
|
$manager->assignedClients()->sync([$mine->id]);
|
|
|
|
$file = File::factory()->create(['uploaded_by' => $manager->id]);
|
|
$token = $manager->createToken('t', [Permission::EditFiles->value])->plainTextToken;
|
|
|
|
// 422 rather than 403: ResolvesShareTargets reports an out-of-scope
|
|
// target as an invalid `id`, which is what the web surface does too and
|
|
// is the weaker disclosure of the two — it does not confirm that the
|
|
// client exists and is merely someone else's.
|
|
$this->withToken($token)
|
|
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $theirs->id])
|
|
->assertStatus(422)
|
|
->assertJsonPath('type', 'validation_failed');
|
|
|
|
expect(FileAssignment::query()->where('file_id', $file->id)->count())->toBe(0);
|
|
});
|
|
|
|
/*
|
|
* Every mutation through the API is distinguishable from the same change
|
|
* made in the UI — the basis for the per-token history the API dashboard
|
|
* will show.
|
|
*/
|
|
test('API mutations are tagged with how they arrived', function () {
|
|
$this->withToken($this->token)->post('/api/v1/files', [
|
|
'file' => UploadedFile::fake()->create('tagged.pdf', 1, 'application/pdf'),
|
|
], ['Accept' => 'application/json'])->assertStatus(201);
|
|
|
|
$entry = ActivityLog::query()->where('action', Action::FileUploaded)->latest('id')->firstOrFail();
|
|
|
|
expect($entry->origin)->toBe(ActivityOrigin::Api)
|
|
->and($entry->api_token_name)->toBe('t');
|
|
});
|
|
|
|
test('the same change made in the UI carries no api tag', function () {
|
|
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->actingAs($this->admin)->patch("/files/{$file->id}", ['name' => 'Renamed in the UI']);
|
|
|
|
$entry = ActivityLog::query()->where('action', Action::FileUpdated)->latest('id')->firstOrFail();
|
|
|
|
expect($entry->origin)->toBe(ActivityOrigin::Ui)
|
|
->and($entry->api_token_id)->toBeNull();
|
|
});
|