From 376a4be6ce210162699ee7a489f47bde63d0fd20 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Wed, 23 Oct 2024 11:44:04 -0400 Subject: [PATCH] [#34722] Add Authentication and Authorization to api routes (#90) * initial sanctum setup and concept api controller protection * add sanctum middleware to api group, update controllers to protect routes, update axios bootstrapping * add reconcile to methods allowed on concept controller without auth * add role and permission models to application * update isVocabularyEditor to use Permission model * [#34722] add phpunit cache to gitignore * [#34722] Scaffold out Concepts Authorization * feat: Set up authorization for Concept model using policies and tests * update user factory for Laravel 11 * fix: Clean up example tests and concept test, simplify concept api controller * feat: Factories and model bindings for Term, Concept, and User * formatting fix * feat: Working concept testing * feat: Create interactive artisan command to add user with role * chore: concepts api controller comment cleanup * feat: Add TermPolicy for managing terms and conditions access * feat: Add authorization for Term API in policy and controller * refactor: Update TermController to use type hinting for Term model * fix: update return status for concept deletion * feat: Add Term API testing * refactor: Update TermController authorization * feat: Add resource authorization to ConceptSourceController * refactor: Update method signatures to type hint ConceptSource model * feat: Add ConceptSources API testing * fix: Update type hinting to use correct variable name --- .env.example | 1 + .gitignore | 1 + app/Console/Commands/AddUser.php | 39 ++++ app/Console/Kernel.php | 2 +- .../Controllers/API/ConceptController.php | 86 +++++-- .../API/ConceptSourceController.php | 34 ++- app/Http/Controllers/API/TermController.php | 32 ++- app/Http/Controllers/ConceptController.php | 29 --- app/Http/Kernel.php | 2 + app/Models/Concept.php | 3 + app/Models/ConceptSource.php | 3 + app/Models/Permission.php | 31 +++ app/Models/Role.php | 39 ++++ app/Models/Term.php | 2 + app/Models/User.php | 68 ++++-- app/Policies/ConceptPolicy.php | 65 ++++++ app/Policies/ConceptSourcePolicy.php | 65 ++++++ app/Policies/TermPolicy.php | 65 ++++++ app/Providers/AuthServiceProvider.php | 5 +- composer.json | 1 + composer.lock | 66 +++++- config/sanctum.php | 83 +++++++ database/factories/ConceptFactory.php | 45 ++++ database/factories/ConceptSourceFactory.php | 28 +++ database/factories/TermFactory.php | 26 +++ database/factories/UserFactory.php | 54 +++-- ...01_create_personal_access_tokens_table.php | 33 +++ resources/js/bootstrap.js | 7 +- routes/api.php | 2 +- tests/Feature/API/ConceptSourcesTest.php | 137 +++++++++++ tests/Feature/API/ConceptsTest.php | 213 ++++++++++++++++++ tests/Feature/API/TermsTest.php | 128 +++++++++++ tests/Feature/ExampleTest.php | 21 -- tests/Unit/.gitkeep | 0 tests/Unit/ExampleTest.php | 18 -- 35 files changed, 1280 insertions(+), 154 deletions(-) create mode 100644 app/Console/Commands/AddUser.php create mode 100644 app/Models/Permission.php create mode 100644 app/Models/Role.php create mode 100644 app/Policies/ConceptPolicy.php create mode 100644 app/Policies/ConceptSourcePolicy.php create mode 100644 app/Policies/TermPolicy.php create mode 100644 config/sanctum.php create mode 100644 database/factories/ConceptFactory.php create mode 100644 database/factories/ConceptSourceFactory.php create mode 100644 database/factories/TermFactory.php create mode 100644 database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php create mode 100644 tests/Feature/API/ConceptSourcesTest.php create mode 100644 tests/Feature/API/ConceptsTest.php create mode 100644 tests/Feature/API/TermsTest.php delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Unit/.gitkeep delete mode 100644 tests/Unit/ExampleTest.php diff --git a/.env.example b/.env.example index 430e204..6fc42bf 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ CACHE_DRIVER=file QUEUE_CONNECTION=sync SESSION_DRIVER=file SESSION_LIFETIME=120 +SESSION_DOMAIN=.ddev.site REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null diff --git a/.gitignore b/.gitignore index 3d39579..34d7010 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .env .env.backup .phpunit.result.cache +.phpunit.cache Homestead.json Homestead.yaml npm-debug.log diff --git a/app/Console/Commands/AddUser.php b/app/Console/Commands/AddUser.php new file mode 100644 index 0000000..418cb65 --- /dev/null +++ b/app/Console/Commands/AddUser.php @@ -0,0 +1,39 @@ +ask('Enter the user\'s first name'); + $lastName = $this->ask('Enter the user\'s last name'); + $email = $this->ask('Enter the user\'s email'); + + // Fetch available roles + $roles = Role::all()->pluck('label')->toArray(); + $role = $this->choice('Select a role for the user', $roles); + + // Create the user + $user = User::create([ + 'first' => $firstName, + 'last' => $lastName, + 'fullname' => "{$firstName} {$lastName}", + 'email' => $email, + 'username' => $email, + ]); + + // Assign the role to the user + $user->assignRole($role); // Ensure you have a method to assign roles + + $this->info("User {$firstName} {$lastName} with role {$role} has been created successfully."); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 69914e9..68dcef2 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,7 +13,7 @@ class Kernel extends ConsoleKernel * @var array */ protected $commands = [ - // + \App\Console\Commands\AddUser::class, ]; /** diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index b53f170..2a0933b 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -13,6 +13,17 @@ class ConceptController extends Controller { + /** + * Instantiate a new controller instance. + * + * @return void + */ + public function __construct() + { + $this->middleware('auth:sanctum')->except(['index', 'show', 'reconcile']); + $this->authorizeResource(Concept::class); + } + /** * Display a listing of the resource. * @@ -102,7 +113,7 @@ public function store(ConceptStoreRequest $request) DB::commit(); return response()->json([ "id" => $concept->id, - ]); + ], 201); } catch (\Throwable $th) { DB::rollback(); @@ -116,24 +127,23 @@ public function store(ConceptStoreRequest $request) /** * Display the specified resource. * - * @param int $id + * @param \App\Concept $concept * @return \Illuminate\Http\Response */ - public function show($id) + public function show(Concept $concept) { - return Concept::findOrFail($id); + return $concept; } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param int $id + * @param \App\Concept $concept * @return \Illuminate\Http\Response */ - public function update(Request $request, int $id) + public function update(Request $request, Concept $concept) { - $concept = Concept::findOrFail($id); $attributes = $request->all(); // Sync concept categories @@ -152,15 +162,50 @@ public function update(Request $request, int $id) return new Response($concept); } + /** + * Relate Concepts + * + * @param \Illuminate\Http\Request $request + * @param \App\Concept $concept + * @return \Illuminate\Http\Response + */ + public function relateConcepts(Request $request, Concept $concept) + { + if ($request->user()->cannot('update', $concept)) { + abort(403); + } + + $relation_type = $request->input('relation_type'); + $related_id = $request->input('related_id'); + + switch ($relation_type) { + case "broader": + $concept->addBroader($related_id); + break; + case "narrower": + $concept->addNarrower($related_id); + break; + case "related": + $concept->addRelated($related_id); + break; + } + + return $concept; + } + /** * Display the specified resource. * - * @param int $id + * @param \Illuminate\Http\Request $request + * @param \App\Concept $concept * @return \Illuminate\Http\Response */ - public function deprecate(Request $request, $id) + public function deprecate(Request $request, Concept $concept) { - $concept = Concept::findOrFail($id); + if ($request->user()->cannot('update', $concept)) { + abort(403); + } + $to = $request->input('to'); if ($to) { $replaceConcept = Concept::findOrFail($to); @@ -169,18 +214,23 @@ public function deprecate(Request $request, $id) $concept->deprecated = !$concept->deprecated; $concept->save(); } - return $concept->deprecated ? 'true' : 'false'; + + return response()->json($concept, 200); } /** * Remove the specified resource from storage. * - * @param int $id + * @param \App\Concept $concept * @return \Illuminate\Http\Response */ - public function destroy($id) + public function destroy(Concept $concept) { - // + $concept->conceptCategories()->detach(); + $concept->terms()->delete(); + $concept->delete(); + + return response('Deleted ' . $concept->id, 204); } /** @@ -199,11 +249,9 @@ public function reconcile(Request $request) 'category' => 'required', ]); - if (isset($request['category'])) { - $category_id = config('cache.category_ids')[$request['category']] ?? null; - $category = isset($category_id) ? $request['category'] : null; - } - $term = $_GET["term"]; + $category_id = config('cache.category_ids')[$request->input('category')] ?? null; + $category = $category_id ? $request->input('category') : null; + $term = $request->input('term'); $terms = DB::table('concepts')->select('concepts.id as id', 'text as name') ->addSelect(DB::raw("true as match, 100 as score, '$category' as type")) diff --git a/app/Http/Controllers/API/ConceptSourceController.php b/app/Http/Controllers/API/ConceptSourceController.php index 7a4585e..2964eff 100644 --- a/app/Http/Controllers/API/ConceptSourceController.php +++ b/app/Http/Controllers/API/ConceptSourceController.php @@ -10,6 +10,17 @@ class ConceptSourceController extends Controller { + /** + * Instantiate a new controller instance. + * + * @return void + */ + public function __construct() + { + $this->middleware('auth:sanctum')->except(['index', 'show']); + $this->authorizeResource(ConceptSource::class); + } + /** * Display a listing of the resource. * @@ -62,37 +73,36 @@ public function store(Request $request) /** * Display the specified resource. * - * @param int $id + * @param ConceptSource $conceptSource * @return \Illuminate\Http\Response */ - public function show($id) + public function show(ConceptSource $conceptSource) { - return ConceptSource::findOrFail($id); + return $conceptSource; } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param int $id + * @param ConceptSource $conceptSource * @return \Illuminate\Http\Response */ - public function update(Request $request, $id) + public function update(Request $request, ConceptSource $conceptSource) { - $source = ConceptSource::findOrFail($id); - $source->update($request->all()); - return $source; + $conceptSource->update($request->all()); + return $conceptSource; } /** * Remove the specified resource from storage. * - * @param int $id + * @param ConceptSource $conceptSource * @return \Illuminate\Http\Response */ - public function destroy($id) + public function destroy(ConceptSource $conceptSource) { - $source = ConceptSource::findOrFail($id)->delete(); - return response('Deleted' . $id, 204); + $conceptSource->delete(); + return response('Deleted ' . $conceptSource->id, 204); } } diff --git a/app/Http/Controllers/API/TermController.php b/app/Http/Controllers/API/TermController.php index 5302131..7466995 100644 --- a/app/Http/Controllers/API/TermController.php +++ b/app/Http/Controllers/API/TermController.php @@ -9,6 +9,17 @@ class TermController extends Controller { + /** + * Instantiate a new controller instance. + * + * @return void + */ + public function __construct() + { + $this->middleware('auth:sanctum')->except(['index', 'show']); + $this->authorizeResource(Term::class); + } + /** * Display a listing of the resource. * @@ -54,24 +65,23 @@ public function store(Request $request) /** * Display the specified resource. * - * @param int $id + * @param \App\Models\Term $term * @return \Illuminate\Http\Response */ - public function show($id) + public function show(Term $term) { - return Term::findOrFail($id); + return $term; } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param int $id + * @param \App\Models\Term $term * @return \Illuminate\Http\Response */ - public function update(Request $request, $id) + public function update(Request $request, Term $term) { - $term = Term::findOrFail($id); $term->update($request->all()); return $term; } @@ -79,13 +89,13 @@ public function update(Request $request, $id) /** * Remove the specified resource from storage. * - * @param int $id + * @param \App\Models\Term $term * @return \Illuminate\Http\Response */ - public function destroy($id) + public function destroy(Term $term) { - $term = Term::findOrFail($id)->delete(); - return response('Deleted', 204); - + $term->delete(); + + return response('Deleted ' . $term->id, 204); } } diff --git a/app/Http/Controllers/ConceptController.php b/app/Http/Controllers/ConceptController.php index 1823c01..be4dc9a 100644 --- a/app/Http/Controllers/ConceptController.php +++ b/app/Http/Controllers/ConceptController.php @@ -223,33 +223,4 @@ public function search(Concept $concept) return $terms->get(); } - - /** - * Relate Concepts - * - * @param \App\Concept $concept - * @return \Illuminate\Http\Response - */ - public function relateConcepts($concept_id) - { - $relation_type = $_GET["relation_type"]; - $related_id = $_GET["related_id"]; - - $concept = Concept::findOrFail($concept_id); - - switch ($relation_type) { - case "broader": - $concept->addBroader($related_id); - break; - case "narrower": - $concept->addNarrower($related_id); - break; - case "related": - $concept->addRelated($related_id); - break; - } - - return $concept; - } - } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 8563286..430f604 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -3,6 +3,7 @@ namespace App\Http; use Illuminate\Foundation\Http\Kernel as HttpKernel; +use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; class Kernel extends HttpKernel { @@ -40,6 +41,7 @@ class Kernel extends HttpKernel ], 'api' => [ + EnsureFrontendRequestsAreStateful::class, 'throttle:60,1', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], diff --git a/app/Models/Concept.php b/app/Models/Concept.php index cda9339..1b6457b 100644 --- a/app/Models/Concept.php +++ b/app/Models/Concept.php @@ -2,10 +2,13 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Concept extends Model { + use HasFactory; + protected $fillable = [ "deprecated", "concept", diff --git a/app/Models/ConceptSource.php b/app/Models/ConceptSource.php index c13df97..1eb5bfe 100644 --- a/app/Models/ConceptSource.php +++ b/app/Models/ConceptSource.php @@ -2,10 +2,13 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class ConceptSource extends Model { + use HasFactory; + protected $fillable = [ "concept_id", "citation", diff --git a/app/Models/Permission.php b/app/Models/Permission.php new file mode 100644 index 0000000..3fd3ac7 --- /dev/null +++ b/app/Models/Permission.php @@ -0,0 +1,31 @@ +belongsToMany(Role::class, 'privilege_role_link', 'pid', 'rid'); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..8b23fc5 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,39 @@ +belongsToMany(User::class, 'appuser_role_link', 'rid', 'uid'); + } + + /** + * The permissions that belong to the role. + */ + public function permissions(): BelongsToMany + { + return $this->belongsToMany(Permission::class, 'privilege_role_link', 'rid', 'pid'); + } +} diff --git a/app/Models/Term.php b/app/Models/Term.php index 0b64794..addd5c1 100644 --- a/app/Models/Term.php +++ b/app/Models/Term.php @@ -2,10 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Term extends Model { + use HasFactory; protected $fillable = [ "text", diff --git a/app/Models/User.php b/app/Models/User.php index 914264c..1aa8c72 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,14 +2,24 @@ namespace App\Models; -use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Support\Facades\DB; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { use Notifiable; + use HasApiTokens; + use HasFactory; + + /** + * Indicates if the model should be timestamped. + * + * @var bool + */ + public $timestamps = false; /** * The attributes that are mass assignable. @@ -30,7 +40,7 @@ class User extends Authenticatable 'work_email', 'work_phone', 'affiliation', - 'preferred_rules' + 'preferred_rules', ]; /** @@ -53,15 +63,47 @@ class User extends Authenticatable protected $table = 'appuser'; - public function isVocabularyEditor() { - $userId = $this->id; - $vocabularyEditorPrivilegeLabel = 'Edit Vocabulary'; - $roles = DB::table('appuser')->join('appuser_role_link', function($join) use ($userId) { - $join->on('appuser.id', '=', 'appuser_role_link.uid')->where('appuser.id', '=', $userId); - })->join('privilege_role_link','privilege_role_link.rid', '=', 'appuser_role_link.rid')-> - join('privilege', function($join) use ($vocabularyEditorPrivilegeLabel) { - $join->on('privilege_role_link.pid', '=', 'privilege.id')->where('privilege.label', '=', $vocabularyEditorPrivilegeLabel); - })->select('appuser_role_link.rid')->get(); - return $roles->count() > 0; + /** + * The roles that belong to the user. + */ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'appuser_role_link', 'uid', 'rid'); + } + + /** + * Assign a role to the user. + * + * @param string $roleName + * @return void + */ + public function assignRole(string $roleName) + { + $role = Role::where('label', $roleName)->firstOrFail(); + $this->roles()->attach($role); + } + + /** + * The permissions that belong to the user. + */ + public function getPermissions() + { + return Permission::whereIn('id', function ($query) { + $query->select('pid') + ->from('privilege_role_link') + ->whereIn('rid', function ($query) { + $query->select('rid') + ->from('appuser_role_link') + ->where('uid', $this->id); + }); + })->get(); + } + + public function isVocabularyEditor() + { + $permissions = $this->getPermissions(); + $vocabEditor = Permission::where('label', 'Edit Vocabulary')->first(); + + return $permissions->contains($vocabEditor); } } diff --git a/app/Policies/ConceptPolicy.php b/app/Policies/ConceptPolicy.php new file mode 100644 index 0000000..62ce152 --- /dev/null +++ b/app/Policies/ConceptPolicy.php @@ -0,0 +1,65 @@ +isVocabularyEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Concept $concept): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Concept $concept): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Concept $concept): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Concept $concept): bool + { + return $user->isVocabularyEditor(); + } +} diff --git a/app/Policies/ConceptSourcePolicy.php b/app/Policies/ConceptSourcePolicy.php new file mode 100644 index 0000000..3906ead --- /dev/null +++ b/app/Policies/ConceptSourcePolicy.php @@ -0,0 +1,65 @@ +isVocabularyEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ConceptSource $conceptSource): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ConceptSource $conceptSource): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ConceptSource $conceptSource): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ConceptSource $conceptSource): bool + { + return $user->isVocabularyEditor(); + } +} diff --git a/app/Policies/TermPolicy.php b/app/Policies/TermPolicy.php new file mode 100644 index 0000000..e5f6f5e --- /dev/null +++ b/app/Policies/TermPolicy.php @@ -0,0 +1,65 @@ +isVocabularyEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Term $term): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Term $term): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Term $term): bool + { + return $user->isVocabularyEditor(); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Term $term): bool + { + return $user->isVocabularyEditor(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index dcac5ca..6ec2186 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,9 +2,9 @@ namespace App\Providers; +use App\Models\User; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; -use App\Models\User; class AuthServiceProvider extends ServiceProvider { @@ -26,8 +26,7 @@ public function boot() { $this->registerPolicies(); - // - Gate::define('edit-vocabulary',function(User $user) { + Gate::define('edit-vocabulary', function (User $user) { return $user->isVocabularyEditor(); }); } diff --git a/composer.json b/composer.json index 46c481f..23664eb 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8", "laravel/framework": "^11.10", + "laravel/sanctum": "^4.0", "laravel/socialite": "^5.0", "laravel/tinker": "^2.9", "laravel/ui": "^4.5", diff --git a/composer.lock b/composer.lock index 86015e9..a412e24 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a96a851d47885af659b74feef5d8caa", + "content-hash": "cb35cc302f9f2d35d350368e1c60935d", "packages": [ { "name": "brick/math", @@ -1475,6 +1475,70 @@ }, "time": "2024-05-27T13:53:20+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/9cfc0ce80cabad5334efff73ec856339e8ec1ac1", + "reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/database": "^11.0", + "illuminate/support": "^11.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2024-04-10T19:39:58+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.3.3", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..764a82f --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,83 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort() + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/ConceptFactory.php b/database/factories/ConceptFactory.php new file mode 100644 index 0000000..ab82567 --- /dev/null +++ b/database/factories/ConceptFactory.php @@ -0,0 +1,45 @@ + + */ +class ConceptFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'deprecated' => false, + ]; + } + + /** + * Indicate that the concept should have categories and terms. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public function configure() + { + $categoryIds = Vocabulary::where('type', 'concept_category')->pluck('id')->toArray(); + + return $this->afterCreating(function (Concept $concept) use ($categoryIds) { + // Attach a random category + $concept->conceptCategories()->attach(Arr::random($categoryIds)); + + // Create terms + Term::factory()->count(3)->create(['concept_id' => $concept->id]); + }); + } +} diff --git a/database/factories/ConceptSourceFactory.php b/database/factories/ConceptSourceFactory.php new file mode 100644 index 0000000..1edb442 --- /dev/null +++ b/database/factories/ConceptSourceFactory.php @@ -0,0 +1,28 @@ + + */ +class ConceptSourceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'citation' => $this->faker->sentence(), + 'concept_id' => Concept::factory(), + 'found_data' => $this->faker->sentence(), + 'note' => $this->faker->paragraph(), + 'url' => $this->faker->url(), + ]; + } +} diff --git a/database/factories/TermFactory.php b/database/factories/TermFactory.php new file mode 100644 index 0000000..f107288 --- /dev/null +++ b/database/factories/TermFactory.php @@ -0,0 +1,26 @@ + + */ +class TermFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'text' => $this->faker->word, + 'preferred' => false, + 'concept_id' => Concept::factory(), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index fc65986..d4f0598 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -1,28 +1,38 @@ + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; -$factory->define(User::class, function (Faker $faker) { - return [ - 'first' => $faker->name, - 'email' => $faker->unique()->safeEmail, - 'email_verified_at' => now(), - 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password - 'remember_token' => Str::random(10), - ]; -}); + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $email = fake()->unique()->safeEmail(); + + return [ + 'active' => true, + 'username' => $email, + 'email' => $email, + 'first' => fake()->firstName(), + 'last' => fake()->lastName(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } +} diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php new file mode 100644 index 0000000..e828ad8 --- /dev/null +++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 63605fa..639de70 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -7,10 +7,10 @@ window._ = require('lodash'); */ try { - window.Popper = require('popper.js').default; - window.$ = window.jQuery = require('jquery'); + window.Popper = require('popper.js').default; + window.$ = window.jQuery = require('jquery'); - require('bootstrap'); + require('bootstrap'); } catch (e) {} /** @@ -22,6 +22,7 @@ try { window.axios = require('axios'); window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; +axios.defaults.withCredentials = true; /** * Echo exposes an expressive API for subscribing to channels and listening diff --git a/routes/api.php b/routes/api.php index a8b3ec3..a4caa45 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,7 +22,7 @@ Route::get('concepts/reconcile/{id}', 'API\ConceptController@reconcile'); Route::get('concepts/reconcile', 'API\ConceptController@reconcile'); -Route::put('concepts/{id}/relate_concept', 'ConceptController@relateConcepts'); +Route::put('concepts/{id}/relate_concept', 'API\ConceptController@relateConcepts'); Route::put('concepts/{id}/deprecate', 'API\ConceptController@deprecate'); Route::apiResource('concepts', 'API\ConceptController'); diff --git a/tests/Feature/API/ConceptSourcesTest.php b/tests/Feature/API/ConceptSourcesTest.php new file mode 100644 index 0000000..67b4aba --- /dev/null +++ b/tests/Feature/API/ConceptSourcesTest.php @@ -0,0 +1,137 @@ +getJson('/api/concept_sources'); + $response->assertStatus(200); + } + + public function test_any_can_get_concept_source(): void + { + $conceptSource = ConceptSource::factory()->create(); + $response = $this->getJson("/api/concept_sources/{$conceptSource->id}"); + $response->assertStatus(200); + } + + public function test_authorized_user_can_create_concept_source(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->postJson('/api/concept_sources', [ + 'citation' => $this->faker->sentence(), + 'concept_id' => $concept->id, + 'found_data' => $this->faker->sentence(), + 'note' => $this->faker->paragraph(), + 'url' => $this->faker->url(), + ]); + + $response->assertStatus(201); + } + + public function test_authorized_user_can_update_concept_source(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $conceptSource = ConceptSource::factory()->create(); + $response = $this->patchJson("/api/concept_sources/{$conceptSource->id}", [ + 'citation' => $this->faker->sentence(), + 'found_data' => $this->faker->sentence(), + 'note' => $this->faker->paragraph(), + 'url' => $this->faker->url(), + ]); + + $response->assertStatus(200); + } + + public function test_authorized_user_can_delete_concept_source(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $conceptSource = ConceptSource::factory()->create(); + $response = $this->deleteJson("/api/concept_sources/{$conceptSource->id}"); + + $response->assertStatus(204); + } + + public function test_unauthorized_user_cannot_create_concept_source(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->postJson('/api/concept_sources', [ + 'citation' => $this->faker->sentence(), + 'concept_id' => $concept->id, + 'found_data' => $this->faker->sentence(), + 'note' => $this->faker->paragraph(), + 'url' => $this->faker->url(), + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_update_concept_source(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $conceptSource = ConceptSource::factory()->create(); + $response = $this->patchJson("/api/concept_sources/{$conceptSource->id}", [ + 'citation' => $this->faker->sentence(), + 'found_data' => $this->faker->sentence(), + 'note' => $this->faker->paragraph(), + 'url' => $this->faker->url(), + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_delete_concept_source(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $conceptSource = ConceptSource::factory()->create(); + $response = $this->deleteJson("/api/concept_sources/{$conceptSource->id}"); + + $response->assertStatus(403); + } +} diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php new file mode 100644 index 0000000..dc10ed3 --- /dev/null +++ b/tests/Feature/API/ConceptsTest.php @@ -0,0 +1,213 @@ +getJson('/api/concepts'); + $response->assertStatus(200); + } + + public function test_any_can_search_concepts(): void + { + $response = $this->getJson('/api/concepts?search=example'); + $response->assertStatus(200); + } + + public function test_any_can_get_concept(): void + { + $concept = Concept::first(); + $response = $this->getJson("/api/concepts/{$concept->id}"); + $response->assertStatus(200); + } + + public function test_any_can_reconcile_concept(): void + { + $response = $this->getJson("api/concepts/reconcile?term=teacher&category=occupation"); + $response->assertStatus(200); + } + + public function test_authorized_user_can_create_concept(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $categoryIds = Vocabulary::where('type', 'concept_category')->pluck('id')->toArray(); + + $response = $this->postJson('/api/concepts', [ + 'preferred_term' => 'preferred', + 'category_id' => Arr::random($categoryIds), + 'alternate_terms' => [ + 'term1', + 'term2', + 'term3' + ], + ]); + + $response->assertStatus(201); + } + + public function test_authorized_user_can_update_concept(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $conceptCategories = Vocabulary::where('type', 'concept_category')->get()->random(2)->toArray(); + $response = $this->patchJson("/api/concepts/{$concept->id}", [ + 'conceptCategories' => $conceptCategories + ]); + + $updatedCategories = Concept::find($concept->id)->conceptCategories->toArray(); + $keysToRemove = ["pivot"]; + $cleanedCategories = array_map(function($item) use ($keysToRemove) { + return array_diff_key($item, array_flip($keysToRemove)); + }, $updatedCategories); + $this->assertEqualsCanonicalizing($conceptCategories, $cleanedCategories); + + $response->assertStatus(200); + } + + public function test_authorized_user_can_update_concept_relationships(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'broader', + 'related_id' => $relatedConcept, + ]); + + $response->assertStatus(200); + } + + public function test_authorized_user_can_deprecate_concept(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->putJson("/api/concepts/{$concept->id}/deprecate"); + + $response->assertStatus(200); + } + + public function test_authorized_user_can_delete_concept(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->deleteJson("/api/concepts/{$concept->id}"); + + $response->assertStatus(204); + } + + public function test_unauthorized_user_cannot_create_concept(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/concepts', [ + 'category_id' => 1, + 'terms' => [['text' => 'New Term']], + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_update_concept(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->putJson("/api/concepts/{$concept->id}", [ + 'terms' => [['text' => 'Updated Term']], + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_update_concept_relationships(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'broader', + 'related_id' => $relatedConcept, + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_deprecate_concept(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->putJson("/api/concepts/{$concept->id}/deprecate"); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_delete_concept(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->deleteJson("/api/concepts/{$concept->id}"); + + $response->assertStatus(403); + } +} diff --git a/tests/Feature/API/TermsTest.php b/tests/Feature/API/TermsTest.php new file mode 100644 index 0000000..743be1e --- /dev/null +++ b/tests/Feature/API/TermsTest.php @@ -0,0 +1,128 @@ +getJson('/api/terms'); + $response->assertStatus(200); + } + + public function test_any_can_get_term(): void + { + $term = Term::first(); + $response = $this->getJson("/api/terms/{$term->id}"); + $response->assertStatus(200); + } + + public function test_authorized_user_can_create_term(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->postJson('/api/terms', [ + 'concept_id' => $concept->id, + 'preferred' => false, + 'text' => $this->faker->word, + ]); + + $response->assertStatus(201); + } + + public function test_authorized_user_can_update_term(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $term = Term::factory()->create(); + $response = $this->patchJson("/api/terms/{$term->id}", [ + 'text' => $this->faker->word + ]); + + $this->assertNotEquals(Term::find($term->id)->text, $term->text); + $response->assertStatus(200); + } + + public function test_authorized_user_can_delete_term(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $term = Term::factory()->create(); + $response = $this->deleteJson("/api/terms/{$term->id}"); + + $response->assertStatus(204); + } + + public function test_unauthorized_user_cannot_create_term(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $response = $this->postJson('/api/terms', [ + 'concept_id' => $concept->id, + 'preferred' => false, + 'text' => $this->faker->word, + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_update_term(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $term = Term::factory()->create(); + $response = $this->patchJson("/api/terms/{$term->id}", [ + 'text' => $this->faker->word + ]); + + $response->assertStatus(403); + } + + public function test_unauthorized_user_cannot_delete_term(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + $term = Term::factory()->create(); + $response = $this->deleteJson("/api/terms/{$term->id}"); + + $response->assertStatus(403); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index cdb5111..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,21 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 358cfc8..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,18 +0,0 @@ -assertTrue(true); - } -}