diff --git a/src/app/Http/Controllers/API/EntitlementsController.php b/src/app/Http/Controllers/API/EntitlementsController.php new file mode 100644 index 00000000..8da78749 --- /dev/null +++ b/src/app/Http/Controllers/API/EntitlementsController.php @@ -0,0 +1,85 @@ +json(['error' => 'unauthorized'], 401); + } + + $result = [$user]; + + $user->entitlements()->each( + function ($entitlement) { + $result[] = User::find($entitlement->user_id); + } + ); + + return response()->json($result); + } + /** * Create a new AuthController instance. * * @return void */ public function __construct() { $this->middleware('auth:api', ['except' => ['login']]); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } return response()->json(['error' => 'Unauthorized'], 401); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { return response()->json($this->guard()->user()); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json(['message' => 'Successfully logged out']); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } public function register(Request $request) { $user = \App\User::create( [ 'email' => $request->email, 'password' => $request->password, ] ); $token = auth()->login($user); return $this->respondWithToken($token); } /** * Get the token array structure. * * @param string $token * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json([ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ]); } + /** + * Display the specified resource. + * + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function show($id) + { + $user = Auth::user(); + + if (!$user) { + return abort(403); + } + + $result = false; + + $user->entitlements()->each( + function ($entitlement) { + if ($entitlement->user_id == $id) { + $result = true; + } + } + ); + + if ($user->id == $id) { + $result = true; + } + + if (!$result) { + return abort(404); + } + + return \App\User::find($id); + } + /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } } diff --git a/src/app/Http/Controllers/API/WalletsController.php b/src/app/Http/Controllers/API/WalletsController.php new file mode 100644 index 00000000..ea6b44ba --- /dev/null +++ b/src/app/Http/Controllers/API/WalletsController.php @@ -0,0 +1,85 @@ +{$user->getKeyName()} = \App\Utils::uuidInt(); - \App\Jobs\ProcessUserCreate::dispatch($user); + // can't dispatch job here because it'll fail serialization } /** * Handle the user "created" event. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { // FIXME: Actual proper settings $user->setSettings( [ 'country' => 'CH', 'currency' => 'CHF', 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '' ] ); $user->wallets()->create(); + + \App\Jobs\ProcessUserCreate::dispatch($user); } /** * Handle the user "updated" event. * * @param \App\User $user * @return void */ public function updated(User $user) { // } /** * Handle the user "deleted" event. * * @param \App\User $user * @return void */ public function deleted(User $user) { } public function deleting(User $user) { \App\Jobs\ProcessUserDelete::dispatch($user); } /** * Handle the user "restored" event. * * @param \App\User $user * @return void */ public function restored(User $user) { // } public function retrieving(User $user) { \App\Jobs\ProcessUserRead::dispatch($user); } public function updating(User $user) { \App\Jobs\ProcessUserUpdate::dispatch($user); } /** * Handle the user "force deleted" event. * * @param \App\User $user * @return void */ public function forceDeleted(User $user) { // } } diff --git a/src/app/User.php b/src/app/User.php index b14f7eba..22194ff3 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,130 +1,132 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * @return Wallet[] */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Entitlements for this user. * * @return Entitlement[] */ public function entitlements() { return $this->hasMany('App\Entitlement'); } public function addEntitlement($entitlement) { if (!$this->entitlements()->get()->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Wallets this user owns. * * @return Wallet[] */ public function wallets() { return $this->hasMany('App\Wallet'); } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } } diff --git a/src/database/database.sqlite b/src/database/database.sqlite index fccfbad4..99329d56 100644 Binary files a/src/database/database.sqlite and b/src/database/database.sqlite differ diff --git a/src/database/factories/UserFactory.php b/src/database/factories/UserFactory.php index 5e516cee..f45d3d37 100644 --- a/src/database/factories/UserFactory.php +++ b/src/database/factories/UserFactory.php @@ -1,27 +1,30 @@ define(User::class, function (Faker $faker) { - return [ - 'name' => $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), - ]; -}); +$factory->define( + User::class, + function (Faker $faker) { + return [ + 'name' => $faker->name, + 'email' => $faker->unique()->safeEmail, + 'email_verified_at' => now(), + 'password' => Str::random(64), + 'remember_token' => Str::random(10), + ]; + } +); diff --git a/src/database/migrations/2014_10_12_000000_create_users_table.php b/src/database/migrations/2014_10_12_000000_create_users_table.php index 4a3ba472..16706952 100644 --- a/src/database/migrations/2014_10_12_000000_create_users_table.php +++ b/src/database/migrations/2014_10_12_000000_create_users_table.php @@ -1,36 +1,37 @@ bigIncrements('id'); - $table->string('name'); + $table->string('name')->nullable(); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->string('password')->nullable(); + $table->string('password_ldap')->nullable(); $table->rememberToken(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('users'); } } diff --git a/src/database/migrations/2019_09_17_093512_users_name_nullable.php b/src/database/migrations/2019_09_17_093512_users_name_nullable.php deleted file mode 100644 index 7578f303..00000000 --- a/src/database/migrations/2019_09_17_093512_users_name_nullable.php +++ /dev/null @@ -1,38 +0,0 @@ -string('name')->nullable()->change(); - } - ); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table( - 'users', - function (Blueprint $table) { - $table->string('name')->nullable(false)->change(); - } - ); - } -} diff --git a/src/database/migrations/2019_09_17_125946_add_password_ldap_column_to_users.php b/src/database/migrations/2019_09_17_125946_add_password_ldap_column_to_users.php deleted file mode 100644 index 306f975d..00000000 --- a/src/database/migrations/2019_09_17_125946_add_password_ldap_column_to_users.php +++ /dev/null @@ -1,38 +0,0 @@ -string('password_ldap')->nullable(); - } - ); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table( - 'users', - function (Blueprint $table) { - $table->dropColumn('password_ldap'); - } - ); - } -} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php index cceafbf0..ac070d7d 100644 --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -1,16 +1,17 @@ call(UserSeeder::class); + $this->call(SkuSeeder::class); } } diff --git a/src/database/seeds/SkuSeeder.php b/src/database/seeds/SkuSeeder.php new file mode 100644 index 00000000..9712dabb --- /dev/null +++ b/src/database/seeds/SkuSeeder.php @@ -0,0 +1,31 @@ + 'individual', + 'description' => 'No friends', + 'cost' => 1.00 + ] + ); + + Sku::create( + [ + 'title' => 'group', + 'description' => 'Some or many friends', + 'cost' => 1.00 + ] + ); + } +} diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index 064d5ce4..e84cdad8 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,28 +1,28 @@ "John Doe", 'email' => 'jdoe@example.org', - 'password_ldap' => 'simple123', + 'password' => 'simple123', 'email_verified_at' => now() ] ); // 10'000 users result in a table size of 11M //factory(User::class, 100)->create(); factory(User::class, 3)->create(); } } diff --git a/src/routes/api.php b/src/routes/api.php index 412bb92f..146175b9 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,28 +1,41 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::get('info', 'API\UsersController@info'); Route::post('login', 'API\UsersController@login'); Route::post('logout', 'API\UsersController@logout'); Route::post('refresh', 'API\UsersController@refresh'); Route::post('register', 'API\UsersController@register'); } ); + +Route::group( + [ + 'middleware' => 'auth:api', + 'prefix' => 'v4' + ], + function () { + Route::apiResource('entitlements', API\EntitlementsController::class); + Route::apiResource('users', API\UsersController::class); + Route::apiResource('wallets', API\WalletsController::class); + } +); + diff --git a/src/tests/Feature/UserAccountTest.php b/src/tests/Feature/UserAccountTest.php new file mode 100644 index 00000000..8b06cec7 --- /dev/null +++ b/src/tests/Feature/UserAccountTest.php @@ -0,0 +1,45 @@ + 'UserAccountA@UserAccount.com' + ] + ); + + $userA->wallets()->each( + function ($wallet) { + $userB = User::firstOrCreate( + [ + 'email' => 'UserAccountB@UserAccount.com' + ] + ); + + $wallet->addController($userB); + } + ); + + $userB = User::firstOrCreate( + [ + 'email' => 'UserAccountB@UserAccount.com' + ] + ); + + $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); + } +} diff --git a/src/tests/Feature/UserEntitlementTest.php b/src/tests/Feature/UserEntitlementTest.php new file mode 100644 index 00000000..0c16fd12 --- /dev/null +++ b/src/tests/Feature/UserEntitlementTest.php @@ -0,0 +1,90 @@ + 'UserEntitlement1@UserEntitlement.com'] + ); + + $user = User::firstOrCreate( + ['email' => 'UserEntitled1@UserEntitlement.com'] + ); + + $entitlement = Entitlement::firstOrCreate( + [ + 'owner_id' => $owner->id, + 'user_id' => $user->id + ] + ); + + $entitlement->delete(); + $user->delete(); + $owner->delete(); + } + + public function testUserAddEntitlement() + { + $sku = Sku::firstOrCreate( + ['title' => 'individual'] + ); + + $owner = User::firstOrCreate( + ['email' => 'UserEntitlement1@UserEntitlement.com'] + ); + + $user = User::firstOrCreate( + ['email' => 'UserEntitled1@UserEntitlement.com'] + ); + + $wallets = $owner->wallets()->get(); + + $entitlement = Entitlement::firstOrCreate( + [ + 'owner_id' => $owner->id, + 'user_id' => $user->id, + 'wallet_id' => $wallets[0]->id, + 'sku_id' => $sku->id, + 'description' => "User Entitlement Test" + ] + ); + + $owner->addEntitlement($entitlement); + + $this->assertTrue($owner->entitlements()->count() == 1); + $this->assertTrue($sku->entitlements()->count() == 1); + $this->assertTrue($wallets[0]->entitlements()->count() == 1); + + $this->assertTrue($wallets[0]->fresh()->balance < 0.00); + } + + public function testUserEntitlements() + { + $userA = User::firstOrCreate( + [ + 'email' => 'UserEntitlement2A@UserEntitlement.com' + ] + ); + + $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); + + $response->assertStatus(200); + $response->assertJson(['id' => $userA->id]); + + $user = factory(User::class)->create(); + $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/UserWalletTest.php b/src/tests/Feature/UserWalletTest.php new file mode 100644 index 00000000..4431e33b --- /dev/null +++ b/src/tests/Feature/UserWalletTest.php @@ -0,0 +1,126 @@ + 'UserWallet1@UserWallet.com' + ] + ); + + $this->assertTrue($user->wallets()->count() == 1); + } + + /** + Verify a user can haz more wallets. + + @return void + */ + public function testAddWallet() + { + $user = User::firstOrCreate( + [ + 'email' => 'UserWallet2@UserWallet.com' + ] + ); + + $user->wallets()->save( + new Wallet(['currency' => 'USD']) + ); + + $this->assertTrue($user->wallets()->count() >= 2); + + $user->wallets()->each( + function ($wallet) { + $this->assertTrue($wallet->balance === 0.00); + } + ); + } + + /** + Verify we can not delete a user wallet that holds balance. + + @return void + */ + public function testDeleteWalletWithCredit() + { + $user = User::firstOrCreate( + [ + 'email' => 'UserWallet3@UserWallet.com' + ] + ); + + $user->wallets()->each( + function ($wallet) { + $wallet->credit(1.00)->save(); + } + ); + + $user->wallets()->each( + function ($wallet) { + $this->assertFalse($wallet->delete()); + } + ); + } + + /** + Verify we can not delete a wallet that is the last wallet. + + @return void + */ + public function testDeleteLastWallet() + { + $user = User::firstOrCreate( + [ + 'email' => 'UserWallet4@UserWallet.com' + ] + ); + + $user->wallets()->each( + function ($wallet) { + $this->assertFalse($wallet->delete()); + } + ); + } + + /** + Verify we can remove a wallet that is an additional wallet. + + @return void + */ + public function testDeleteAddtWallet() + { + $user = User::firstOrCreate( + [ + 'email' => 'UserWallet5@UserWallet.com' + ] + ); + + $user->wallets()->save( + new Wallet(['currency' => 'USD']) + ); + + $user->wallets()->each( + function ($wallet) { + if ($wallet->currency == 'USD') { + $this->assertNotFalse($wallet->delete()); + } + } + ); + } +} diff --git a/src/tests/Feature/UsersApiControllerTest.php b/src/tests/Feature/UsersApiControllerTest.php new file mode 100644 index 00000000..a6a00b3a --- /dev/null +++ b/src/tests/Feature/UsersApiControllerTest.php @@ -0,0 +1,72 @@ + 'UsersApiControllerTest1@UsersApiControllerTest.com' + ] + ); + + $user->delete(); + } + + public function testRegisterUser() + { + $data = [ + 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', + 'password' => 'simple123' + ]; + + $response = $this->post('/api/v4/users/register', $data); + $response->assertStatus(201); + } + + public function testListUsers() + { + $user = User::firstOrCreate( + [ + 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com' + ] + ); + + $response = $this->actingAs($user)->get("api/v4/users"); + + $response->assertJsonCount(1); + + $response->assertStatus(200); + } + + /** + {@inheritDoc} + + @return void + */ + public function tearDown(): void + { + $user = User::firstOrCreate( + [ + 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com' + ] + ); + + $user->delete(); + + parent::tearDown(); + } +} diff --git a/src/tests/Feature/WalletControllerTest.php b/src/tests/Feature/WalletControllerTest.php new file mode 100644 index 00000000..f22e9cc7 --- /dev/null +++ b/src/tests/Feature/WalletControllerTest.php @@ -0,0 +1,96 @@ + 'WalletControllerA@WalletController.com' + ] + ); + + $userA->wallets()->each( + function ($wallet) { + $userB = User::firstOrCreate( + [ + 'email' => 'WalletControllerB@WalletController.com' + ] + ); + + $wallet->addController($userB); + } + ); + + $userB = User::firstOrCreate( + [ + 'email' => 'WalletControllerB@WalletController.com' + ] + ); + + $this->assertTrue($userB->accounts()->count() == 1); + + $aWallet = $userA->wallets()->get(); + $bAccount = $userB->accounts()->get(); + + $this->assertTrue($bAccount[0]->id === $aWallet[0]->id); + } + + /** + Verify controllers can also be removed from wallets. + + @return void + */ + public function testRemoveWalletController() + { + $userA = User::firstOrCreate( + [ + 'email' => 'WalletController2A@WalletController.com' + ] + ); + + $userA->wallets()->each( + function ($wallet) { + $userB = User::firstOrCreate( + [ + 'email' => 'WalletController2B@WalletController.com' + ] + ); + + $wallet->addController($userB); + } + ); + + $userB = User::firstOrCreate( + [ + 'email' => 'WalletController2B@WalletController.com' + ] + ); + + $userB->accounts()->each( + function ($wallet) { + $userB = User::firstOrCreate( + [ + 'email' => 'WalletController2B@WalletController.com' + ] + ); + + $wallet->removeController($userB); + } + ); + + $this->assertTrue($userB->accounts()->count() == 0); + } +}