diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 865652bf..1d402924 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,430 +1,440 @@ orderByDesc('title')->get() ->map(function ($plan) use (&$plans) { // Allow themes to set custom button label $button = \trans('theme::app.planbutton-' . $plan->title); if ($button == 'theme::app.planbutton-' . $plan->title) { $button = \trans('app.planbutton', ['plan' => $plan->name]); } $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => $button, 'description' => $plan->description, 'mode' => $plan->mode ?: 'email', ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { $rules = [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ]; $plan = $this->getPlan(); if ($plan->mode == 'token') { $rules['token'] = ['required', 'string', new SignupToken()]; } else { $rules['email'] = ['required', 'string', new SignupExternalEmail()]; } // Check required fields, validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()->toArray()], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $plan->mode == 'token' ? $request->token : $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $plan->title, 'voucher' => $request->voucher, ]); $response = [ 'status' => 'success', 'code' => $code->code, 'mode' => $plan->mode ?: 'email', ]; if ($plan->mode == 'token') { // Token verification, jump to the last step $has_domain = $plan->hasDomain(); $response['short_code'] = $code->short_code; $response['is_domain'] = $has_domain; $response['domains'] = $has_domain ? [] : Domain::getPublicDomains(); } else { // External email verification, send an email message SignupVerificationEmail::dispatch($code); } return response()->json($response); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request + * @param bool $update Update the signup code record * * @return \Illuminate\Http\JsonResponse JSON response */ - public function verify(Request $request) + public function verify(Request $request, $update = true) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it - // with single SQL query (->delete()) instead of two (::destroy()) + // with single SQL query (->delete()) instead of two $request->code = $code; + if ($update) { + $code->verify_ip_address = $request->ip(); + $code->save(); + } + $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => ['required', 'confirmed', new Password()], 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Signup via invitation if ($request->invitation) { $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // Validate verification codes (again) - $v = $this->verify($request); + $v = $this->verify($request, false); if ($v->status() !== 200) { return $v; } // Get user name/email from the verification code database $code_data = $v->getData(); $settings = [ 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; if ($this->getPlan()->mode == 'token') { $settings['signup_token'] = $code_data->email; } else { $settings['external_email'] = $code_data->email; } } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; // Validate login if ($errors = self::validateLogin($login, $domain_name, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, ]); if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSettings($settings); // Update the invitation if (!empty($invitation)) { $invitation->status = SignupInvitation::STATUS_COMPLETED; $invitation->user_id = $user->id; $invitation->save(); } - // Remove the verification code + // Soft-delete the verification code, and store some more info with it if ($request->code) { - $request->code->delete(); + $request->code->user_id = $user->id; + $request->code->submit_ip_address = $request->ip(); + $request->code->deleted_at = \now(); + $request->code->timestamps = false; + $request->code->save(); } DB::commit(); return AuthController::logonResponse($user, $request->password); } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { $request = request(); if (!$request->plan || !$request->plan instanceof Plan) { // Get the plan if specified and exists... if ($request->code && $request->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); } elseif ($request->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } $request->plan = $plan; } return $request->plan; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::withTrashed()->where('namespace', $domain)->exists()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php index f6fb4199..bd9cfcbc 100644 --- a/src/app/SignupCode.php +++ b/src/app/SignupCode.php @@ -1,88 +1,95 @@ The attributes that are mass assignable */ protected $fillable = [ 'code', 'email', 'expires_at', 'first_name', 'last_name', 'plan', 'short_code', 'voucher' ]; /** @var array The attributes that should be cast */ protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', 'expires_at' => 'datetime:Y-m-d H:i:s', 'headers' => 'array' ]; /** * Check if code is expired. * * @return bool True if code is expired, False otherwise */ public function isExpired() { // @phpstan-ignore-next-line return $this->expires_at ? Carbon::now()->gte($this->expires_at) : false; } /** * Generate a short code (for human). * * @return string */ public static function generateShortCode(): string { $code_length = env('SIGNUP_CODE_LENGTH', self::SHORTCODE_LENGTH); return \App\Utils::randStr($code_length); } } diff --git a/src/database/migrations/2022_10_10_100000_signup_codes_user_id.php b/src/database/migrations/2022_10_10_100000_signup_codes_user_id.php new file mode 100644 index 00000000..57cf3e9c --- /dev/null +++ b/src/database/migrations/2022_10_10_100000_signup_codes_user_id.php @@ -0,0 +1,46 @@ +string('verify_ip_address')->index()->nullable(); + $table->string('submit_ip_address')->index()->nullable(); + + $table->bigInteger('user_id')->index()->nullable(); + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'signup_codes', + function (Blueprint $table) { + $table->dropColumn('user_id'); + $table->dropColumn('verify_ip_address'); + $table->dropColumn('submit_ip_address'); + } + ); + } +}; diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index a2345bc8..63a4dd41 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,894 +1,917 @@ domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); SI::truncate(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); SI::truncate(); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup */ public function testSignupPlans(): void { $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $this->assertArrayHasKey('button', $json['plans'][0]); } /** * Test fetching invitation */ public function testSignupInvitations(): void { Queue::fake(); $invitation = SI::create(['email' => 'email1@ext.com']); // Test existing invitation $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($invitation->id, $json['id']); // Test non-existing invitation $response = $this->get("/api/auth/signup/invitations/abc"); $response->assertStatus(404); // Test completed invitation SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]); $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); $response->assertStatus(404); } /** * Test signup initialization with invalid input */ public function testSignupInitInvalidInput(): void { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'first_name' => str_repeat('a', 250), 'last_name' => str_repeat('a', 250), ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('first_name', $json['errors']); $this->assertArrayHasKey('last_name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Sanity check on voucher code, last/first name is optional $data = [ 'voucher' => '123456789012345678901234567890123', 'email' => 'valid@email.com', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // Email address too long $data = [ 'email' => str_repeat('a', 190) . '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); SignupCode::truncate(); // Email address limit check $data = [ 'email' => 'test@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; \config(['app.signup.email_limit' => 0]); $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); \config(['app.signup.email_limit' => 1]); $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); // TODO: This probably should be a different message? $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); // IP address limit check $data = [ 'email' => 'ip@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; \config(['app.signup.email_limit' => 0]); \config(['app.signup.ip_limit' => 0]); $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']); $json = $response->json(); $response->assertStatus(200); \config(['app.signup.ip_limit' => 1]); $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); // TODO: This probably should be a different message? $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); // TODO: Test phone validation } /** * Test signup initialization with valid input */ public function testSignupInitValidInput(): array { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'individual', ]; - $response = $this->post('/api/auth/signup/init', $data); + $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.2']); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); + $code = SignupCode::find($json['code']); + + $this->assertSame('10.1.1.2', $code->ip_address); + $this->assertSame(null, $code->verify_ip_address); + $this->assertSame(null, $code->submit_ip_address); + // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Try the same with voucher $data['voucher'] = 'TEST'; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->voucher === $data['voucher'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'plan' => $data['plan'], 'voucher' => $data['voucher'] ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput */ public function testSignupVerifyInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput */ public function testSignupVerifyValidInput(array $result): array { $code = SignupCode::find($result['code']); + $code->ip_address = '10.1.1.2'; + $code->save(); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; - $response = $this->post('/api/auth/signup/verify', $data); + $response = $this->post('/api/auth/signup/verify', $data, ['REMOTE_ADDR' => '10.1.1.3']); $json = $response->json(); $response->assertStatus(200); $this->assertCount(7, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['first_name'], $json['first_name']); $this->assertSame($result['last_name'], $json['last_name']); $this->assertSame($result['voucher'], $json['voucher']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); + $code->refresh(); + + $this->assertSame('10.1.1.2', $code->ip_address); + $this->assertSame('10.1.1.3', $code->verify_ip_address); + $this->assertSame(null, $code->submit_ip_address); + return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput */ public function testSignupInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short, password too short $data = [ 'login' => '1', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); $code = SignupCode::find($result['code']); // Data with invalid voucher $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $result['code'], 'short_code' => $code->short_code, 'voucher' => 'XXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // Valid code, invalid login $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput */ public function testSignupValidInput(array $result): void { $queue = Queue::fake(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); + $code->ip_address = '10.1.1.2'; + $code->verify_ip_address = '10.1.1.3'; + $code->save(); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $code->code, 'short_code' => $code->short_code, 'voucher' => 'TEST', ]; - $response = $this->post('/api/auth/signup', $data); + $response = $this->post('/api/auth/signup', $data, ['REMOTE_ADDR' => '10.1.1.4']); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); $this->assertSame($identity, $json['email']); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === \strtolower($data['login'] . '@' . $data['domain']); } ); - // Check if the code has been removed - $this->assertNull(SignupCode::where('code', $result['code'])->first()); + $code->refresh(); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); + // Check if the code has been updated and soft-deleted + $this->assertTrue($code->trashed()); + $this->assertSame('10.1.1.2', $code->ip_address); + $this->assertSame('10.1.1.3', $code->verify_ip_address); + $this->assertSame('10.1.1.4', $code->submit_ip_address); + $this->assertSame($user->id, $code->user_id); + // Check user settings $this->assertSame($result['first_name'], $user->getSetting('first_name')); $this->assertSame($result['last_name'], $user->getSetting('last_name')); $this->assertSame($result['email'], $user->getSetting('external_email')); // Discount $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account */ public function testSignupGroupAccount(): void { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(7, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['first_name'], $result['first_name']); $this->assertSame($user_data['last_name'], $result['last_name']); $this->assertSame(null, $result['voucher']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame("$login@$domain", $result['email']); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainNamespace === $domain; } ); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === $data['login'] . '@' . $data['domain']; } ); // Check if the code has been removed $this->assertNull(SignupCode::find($code->id)); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); $this->assertSame($user_data['first_name'], $user->getSetting('first_name')); $this->assertSame($user_data['last_name'], $user->getSetting('last_name')); // TODO: Check domain record // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup via invitation */ public function testSignupViaInvitation(): void { Queue::fake(); $invitation = SI::create(['email' => 'email1@ext.com']); $post = [ 'invitation' => 'abc', 'first_name' => 'Signup', 'last_name' => 'User', 'login' => 'test-inv', 'domain' => 'kolabnow.com', 'password' => 'testtest', 'password_confirmation' => 'testtest', ]; // Test invalid invitation identifier $response = $this->post('/api/auth/signup', $post); $response->assertStatus(404); // Test valid input $post['invitation'] = $invitation->id; $response = $this->post('/api/auth/signup', $post); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame('test-inv@kolabnow.com', $result['email']); // Check if the user has been created $user = User::where('email', 'test-inv@kolabnow.com')->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($invitation->email, $user->getSetting('external_email')); $this->assertSame($post['first_name'], $user->getSetting('first_name')); $this->assertSame($post['last_name'], $user->getSetting('last_name')); $invitation->refresh(); $this->assertSame($user->id, $invitation->user_id); $this->assertTrue($invitation->isCompleted()); // TODO: Test POST params validation } /** * List of login/domain validation cases for testValidateLogin() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $domain, false, ['login' => 'The login field is required.']], ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['test.test', $domain, false, null], ['test_test', $domain, false, null], ['test-test', $domain, false, null], ['admin', $domain, false, ['login' => 'The specified login is not available.']], ['administrator', $domain, false, ['login' => 'The specified login is not available.']], ['sales', $domain, false, ['login' => 'The specified login is not available.']], ['root', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], ]; } /** * Signup login/domain validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } /** * Signup login/domain validation, more cases */ public function testValidateLoginMore(): void { Queue::fake(); // Test registering for an email of an existing group $login = 'group-test'; $domain = 'kolabnow.com'; $group = $this->getTestGroup("{$login}@{$domain}"); $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for an email of an existing, but soft-deleted group $group->delete(); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for an email of an existing user $domain = $this->getPublicDomain(); $login = 'signuplogin'; $user = $this->getTestUser("{$login}@{$domain}"); $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for an email of an existing, but soft-deleted user $user->delete(); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for a domain that exists $external = true; $domain = $this->getTestDomain( 'external.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain->namespace, $external]); $this->assertSame(['domain' => 'The specified domain is not available.'], $result); // Test registering for a domain that exists but is soft-deleted $domain->delete(); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain->namespace, $external]); $this->assertSame(['domain' => 'The specified domain is not available.'], $result); } }