mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
6e47d76ba6
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.
182 lines
6.9 KiB
PHP
182 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Api\Events\RegisteringApiModules;
|
|
use App\Modules\Platform\Capabilities\Edition;
|
|
use Illuminate\Routing\Route as RouteInstance;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The frame core holds around module-provided endpoints
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| cloud-modules and community-modules register their API routes through
|
|
| RegisteringApiModules. These assert the constraints core applies on their
|
|
| behalf, and that nothing can register outside the space reserved for it.
|
|
|
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
User::factory()->create();
|
|
});
|
|
|
|
test('a module slug must be well formed', function (string $slug) {
|
|
$event = new RegisteringApiModules;
|
|
|
|
expect(fn () => $event->register($slug, fn () => null, null))
|
|
->toThrow(InvalidArgumentException::class);
|
|
})->with([
|
|
'uppercase' => 'Branding',
|
|
'underscore' => 'custom_assets',
|
|
'path traversal' => '../../etc',
|
|
'slash' => 'a/b',
|
|
'leading hyphen' => '-branding',
|
|
'empty' => '',
|
|
]);
|
|
|
|
test('two modules cannot share a slug', function () {
|
|
$event = new RegisteringApiModules;
|
|
$event->register('branding', fn () => null, null);
|
|
|
|
expect(fn () => $event->register('branding', fn () => null, null))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
test('a module cannot declare a capability core does not know', function () {
|
|
$event = new RegisteringApiModules;
|
|
|
|
expect(fn () => $event->register('branding', fn () => null, 'invented.capability'))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
test('a missing route file is rejected at registration', function () {
|
|
$event = new RegisteringApiModules;
|
|
|
|
expect(fn () => $event->register('branding', '/nonexistent/api-routes.php', null))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
test('registered modules are reported for discovery', function () {
|
|
$event = new RegisteringApiModules;
|
|
$event->register('branding', fn () => null, 'branding.customize');
|
|
$event->register('custom-assets', fn () => null, 'custom_assets.manage');
|
|
|
|
expect($event->slugs())->toBe(['branding', 'custom-assets']);
|
|
});
|
|
|
|
/*
|
|
* The boundary itself: a package may not claim a path under /api/v1
|
|
* except beneath /api/v1/modules. Without this a package could shadow or
|
|
* extend a core endpoint and inherit its trust.
|
|
*
|
|
* Keyed off the controller's namespace rather than a hardcoded list of
|
|
* core URIs — the packages are the thing being constrained, and a list of
|
|
* core routes would need editing every time core gains an endpoint, which
|
|
* makes it a chore that gets rubber-stamped rather than a guard.
|
|
*/
|
|
test('no package route escapes the modules prefix', function () {
|
|
$packageNamespace = 'ProjectSend\\';
|
|
|
|
$offenders = collect(Route::getRoutes()->getRoutes())
|
|
->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/'))
|
|
->reject(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/'))
|
|
->filter(function (RouteInstance $route) use ($packageNamespace): bool {
|
|
$action = $route->getAction('controller') ?? '';
|
|
|
|
return is_string($action) && str_starts_with($action, $packageNamespace);
|
|
})
|
|
->map(fn (RouteInstance $route): string => $route->uri())
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
expect($offenders)->toBe([]);
|
|
});
|
|
|
|
test('core api routes all carry the staff token stack', function () {
|
|
// The other half of the same boundary: whatever core adds under
|
|
// /api/v1, it must not be reachable without a staff token. A new
|
|
// endpoint declared outside the group in routes/api.php would be.
|
|
//
|
|
// The allowlist is a single documented exception rather than a loosened
|
|
// rule: adding a second unauthenticated endpoint has to be a deliberate
|
|
// edit here, with a reason next to it.
|
|
$publicByDesign = [
|
|
// The specification itself — a client needs it before it can have a
|
|
// token, and it describes the API's shape, never this
|
|
// installation's data (asserted in OpenApiContractTest).
|
|
'api/v1/openapi.json',
|
|
];
|
|
|
|
$unguarded = collect(Route::getRoutes()->getRoutes())
|
|
->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/'))
|
|
->reject(fn (RouteInstance $route): bool => in_array($route->uri(), $publicByDesign, true))
|
|
->reject(fn (RouteInstance $route): bool => in_array('staff-token', $route->gatherMiddleware(), true))
|
|
->map(fn (RouteInstance $route): string => $route->uri())
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
expect($unguarded)->toBe([]);
|
|
});
|
|
|
|
/*
|
|
* With a real module installed, the edition gate must actually bite.
|
|
*
|
|
* Physical absence is the primary control — cloud-modules is simply not
|
|
* installed on a community deployment — but "present anyway" is the case
|
|
* that has gone wrong before: CustomAssets once shipped routes that
|
|
* answered 200 in an edition where the feature was never supposed to
|
|
* exist, because restating the capability was left to the package. Here
|
|
* the host applies it from the module's own declaration, so it cannot be
|
|
* forgotten.
|
|
*
|
|
* Skipped where no module is installed, which includes CI.
|
|
*/
|
|
test('a module endpoint is refused in an edition without its capability', function () {
|
|
$moduleRoute = collect(Route::getRoutes()->getRoutes())
|
|
->first(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/'));
|
|
|
|
if ($moduleRoute === null) {
|
|
expect(true)->toBeTrue();
|
|
|
|
return;
|
|
}
|
|
|
|
$staff = User::factory()->create();
|
|
$token = $staff->createToken('t', ['edit_settings'])->plainTextToken;
|
|
|
|
// Branding is cloud-only; the suite runs as community by default.
|
|
$this->withToken($token)->getJson('/'.$moduleRoute->uri())->assertForbidden();
|
|
|
|
config(['projectsend.edition' => Edition::Cloud]);
|
|
forgetRequestState();
|
|
|
|
$this->withToken($token)->getJson('/'.$moduleRoute->uri())->assertOk();
|
|
});
|
|
|
|
test('every module route inherits the core auth stack', function () {
|
|
$moduleRoutes = collect(Route::getRoutes()->getRoutes())
|
|
->filter(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/modules/'));
|
|
|
|
if ($moduleRoutes->isEmpty()) {
|
|
// Neither package is installed in this checkout. Skipping is
|
|
// correct — the invariant is about installed modules, and a
|
|
// failure here would just mean "no packages present".
|
|
expect(true)->toBeTrue();
|
|
|
|
return;
|
|
}
|
|
|
|
$moduleRoutes->each(function (RouteInstance $route): void {
|
|
$middleware = $route->gatherMiddleware();
|
|
|
|
expect($middleware)->toContain('auth:sanctum')
|
|
->and($middleware)->toContain('staff-token')
|
|
->and($middleware)->toContain('api-active');
|
|
});
|
|
});
|