Files
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

146 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Identity\Permissions\Permission;
beforeEach(function () {
$this->admin = User::factory()->create();
confirmPassword($this->admin);
});
test('the index lists tokens and the create page holds the form', function () {
$this->admin->createToken('Zapier', [Permission::Upload->value]);
$this->actingAs($this->admin)->get('/settings/api-tokens')
->assertOk()
->assertInertia(fn ($page) => $page
->component('settings/api-tokens/index')
->where('tokens.0.name', 'Zapier')
// The form's data belongs to the create screen now.
->missing('available_abilities'));
$this->actingAs($this->admin)->get('/settings/api-tokens/create')
->assertOk()
->assertInertia(fn ($page) => $page->component('settings/api-tokens/create')->has('available_abilities'));
});
test('a token can be renamed, re-scoped and given a new expiry', function () {
$created = $this->admin->createToken('Old name', [Permission::Upload->value], now()->addDays(5));
$this->actingAs($this->admin)->patch("/settings/api-tokens/{$created->accessToken->getKey()}", [
'name' => 'New name',
'abilities' => [Permission::Upload->value, Permission::EditFiles->value],
'expires_in_days' => 60,
])->assertRedirect(route('api-tokens.index'));
$token = $created->accessToken->refresh();
expect($token->name)->toBe('New name')
->and($token->abilities)->toBe([Permission::Upload->value, Permission::EditFiles->value])
// Counted from now, not from the original issue date.
->and(now()->diffInDays($token->expires_at))->toBeGreaterThan(50);
});
test('editing never changes the secret', function () {
$created = $this->admin->createToken('Zapier', [Permission::Upload->value], now()->addDays(5));
$hashBefore = $created->accessToken->token;
$this->actingAs($this->admin)->patch("/settings/api-tokens/{$created->accessToken->getKey()}", [
'name' => 'Renamed',
'abilities' => [Permission::Upload->value],
'expires_in_days' => 30,
])->assertRedirect();
// The whole point of editing rather than recreating: whatever holds the
// secret keeps working.
expect($created->accessToken->refresh()->token)->toBe($hashBefore);
});
test('the edit page never exposes the secret', function () {
$created = $this->admin->createToken('Zapier', [Permission::Upload->value]);
$body = $this->actingAs($this->admin)
->get("/settings/api-tokens/{$created->accessToken->getKey()}/edit")
->assertOk()
->getContent();
expect($body)->not->toContain(explode('|', $created->plainTextToken)[1])
->and($body)->not->toContain($created->accessToken->token);
});
test('editing cannot grant an ability the owner lacks', function () {
$limited = staffWithPermissions([Permission::Upload->value]);
confirmPassword($limited);
$created = $limited->createToken('Mine', [Permission::Upload->value], now()->addDays(5));
$this->actingAs($limited)->patch("/settings/api-tokens/{$created->accessToken->getKey()}", [
'name' => 'Mine',
'abilities' => [Permission::DeleteOthersFiles->value],
'expires_in_days' => 30,
])->assertSessionHasErrors('abilities.0');
expect($created->accessToken->refresh()->abilities)->toBe([Permission::Upload->value]);
});
test('editing requires a fresh password confirmation', function () {
$this->flushSession();
$unconfirmed = User::factory()->create();
$created = $unconfirmed->createToken('Theirs', [Permission::Upload->value], now()->addDays(5));
$this->actingAs($unconfirmed)->patch("/settings/api-tokens/{$created->accessToken->getKey()}", [
'name' => 'Escalated',
'abilities' => [Permission::Upload->value],
'expires_in_days' => 30,
])->assertRedirect(route('password.confirm'));
expect($created->accessToken->refresh()->name)->toBe('Theirs');
});
test('one staff member cannot read or edit another is token', function () {
$other = User::factory()->create();
$created = $other->createToken('Theirs', [Permission::Upload->value], now()->addDays(5));
$id = $created->accessToken->getKey();
// 404 rather than 403, so token ids cannot be probed for existence.
$this->actingAs($this->admin)->get("/settings/api-tokens/{$id}/edit")->assertNotFound();
$this->actingAs($this->admin)->patch("/settings/api-tokens/{$id}", [
'name' => 'Hijacked',
'abilities' => [Permission::Upload->value],
'expires_in_days' => 30,
])->assertNotFound();
expect($created->accessToken->refresh()->name)->toBe('Theirs');
});
test('an ability change is audited with what moved', function () {
$created = $this->admin->createToken('Zapier', [Permission::Upload->value], now()->addDays(5));
$this->actingAs($this->admin)->patch("/settings/api-tokens/{$created->accessToken->getKey()}", [
'name' => 'Zapier',
'abilities' => [Permission::EditFiles->value],
'expires_in_days' => 30,
])->assertRedirect();
$entry = ActivityLog::query()->where('action', Action::ApiTokenUpdated)->latest('id')->firstOrFail();
expect($entry->context['abilities_added'])->toBe([Permission::EditFiles->value])
->and($entry->context['abilities_removed'])->toBe([Permission::Upload->value]);
});
test('abilities that no longer apply are surfaced before they are dropped', function () {
// A token holding something the API no longer reads: it does nothing
// today, and saving would silently remove it, so the edit screen says so.
$created = $this->admin->createToken('Legacy', [Permission::Upload->value, 'view_news'], now()->addDays(5));
$this->actingAs($this->admin)->get("/settings/api-tokens/{$created->accessToken->getKey()}/edit")
->assertOk()
->assertInertia(fn ($page) => $page
->where('token.retired_abilities', ['view_news'])
->where('token.abilities', [Permission::Upload->value]));
});