diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 46002122..bc715f2c 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,554 +1,554 @@ orderBy('months')->orderByDesc('title')->get() ->map(function ($plan) { $button = self::trans("app.planbutton-{$plan->title}"); if (strpos($button, 'app.planbutton') !== false) { $button = self::trans('app.planbutton', ['plan' => $plan->name]); } return [ 'title' => $plan->title, 'name' => $plan->name, 'button' => $button, 'description' => $plan->description, 'mode' => $plan->mode ?: Plan::MODE_EMAIL, 'isDomain' => $plan->hasDomain(), ]; }) ->all(); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Returns list of public domains for signup. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function domains(Request $request) { return response()->json(['status' => 'success', 'domains' => Domain::getPublicDomains()]); } /** * 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 == 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 == 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 == 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, $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 $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(), ]); } /** * Validates the input to the final signup request. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signupValidate(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); } $settings = []; // Plan parameter is required/allowed in mandate mode if (!empty($request->plan) && empty($request->code) && empty($request->invitation)) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); if (!$plan || $plan->mode != Plan::MODE_MANDATE) { $msg = \trans('validation.exists', ['attribute' => 'plan']); return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422); } } elseif ($request->invitation) { // Signup via 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', ] ); $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, false); if ($v->status() !== 200) { return $v; } $plan = $this->getPlan(); // 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 ($plan->mode == Plan::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); } } if (empty($plan)) { $plan = $this->getPlan(); } $is_domain = $plan->hasDomain(); // Validate login if ($errors = self::validateLogin($request->login, $request->domain, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Set some properties for signup() method $request->settings = $settings; $request->plan = $plan; $request->discount = $discount ?? null; $request->invitation = $invitation ?? null; $result = []; if ($plan->mode == Plan::MODE_MANDATE) { $result = $this->mandateForPlan($plan, $request->discount); } - return response()->json($result); + return response()->json($result + ['status' => 'success']); } /** * 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) { $v = $this->signupValidate($request); if ($v->status() !== 200) { return $v; } $is_domain = $request->plan->hasDomain(); // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($request->login); $domain_name = Str::lower($request->domain); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, 'status' => User::STATUS_RESTRICTED, ]); if ($request->discount) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($request->discount); $wallet->save(); } $user->assignPlan($request->plan, $domain); // Save the external email and plan in user settings $user->setSettings($request->settings); // Update the invitation if ($request->invitation) { $request->invitation->status = SignupInvitation::STATUS_COMPLETED; $request->invitation->user_id = $user->id; $request->invitation->save(); } // Soft-delete the verification code, and store some more info with it if ($request->code) { $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(); $response = AuthController::logonResponse($user, $request->password); if ($request->plan->mode == Plan::MODE_MANDATE) { $data = $response->getData(true); $data['checkout'] = $this->mandateForPlan($request->plan, $request->discount, $user); $response->setData($data); } return $response; } /** * Collects some content to display to the user before redirect to a checkout page. * Optionally creates a recurrent payment mandate for specified user/plan. */ protected function mandateForPlan(Plan $plan, Discount $discount = null, User $user = null): array { $result = []; $min = \App\Payment::MIN_AMOUNT; $planCost = $plan->cost() * $plan->months; if ($discount) { - $planCost -= ceil($planCost * (100 - $discount->discount) / 100); + $planCost = (int) ($planCost * (100 - $discount->discount) / 100); } if ($planCost > $min) { $min = $planCost; } if ($user) { $wallet = $user->wallets()->first(); $wallet->setSettings([ 'mandate_amount' => sprintf('%.2f', round($min / 100, 2)), 'mandate_balance' => 0, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => \App\Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); } $params = [ 'cost' => \App\Utils::money($planCost, \config('app.currency')), 'period' => \trans($plan->months == 12 ? 'app.period-year' : 'app.period-month'), ]; $content = '' . self::trans('app.signup-account-tobecreated') . '

' . self::trans('app.signup-account-summary', $params) . '

' . self::trans('app.signup-account-mandate', $params); $result['content'] = $content; return $result; } /** * 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 instanceof SignupCode) && $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/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 362e2fc4..cd6e5de9 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,1004 +1,1080 @@ 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(); Plan::where('title', 'test')->delete(); } /** * {@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(); Plan::where('title', 'test')->delete(); 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 public domains for signup */ public function testSignupDomains(): void { $response = $this->get('/api/auth/signup/domains'); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame(Domain::getPublicDomains(), $json['domains']); } /** * Test fetching plans for signup */ public function testSignupPlans(): void { $individual = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $group = Plan::withEnvTenantContext()->where('title', 'group')->first(); $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertSame($individual->title, $json['plans'][0]['title']); $this->assertSame($individual->name, $json['plans'][0]['name']); $this->assertSame($individual->description, $json['plans'][0]['description']); $this->assertFalse($json['plans'][0]['isDomain']); $this->assertArrayHasKey('button', $json['plans'][0]); $this->assertSame($group->title, $json['plans'][1]['title']); $this->assertSame($group->name, $json['plans'][1]['name']); $this->assertSame($group->description, $json['plans'][1]['description']); $this->assertTrue($json['plans'][1]['isDomain']); $this->assertArrayHasKey('button', $json['plans'][1]); } /** * 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, ['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, ['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, ['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']); } ); $code->refresh(); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertTrue($user->isRestricted()); // 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 $code->refresh(); $this->assertTrue($code->trashed()); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); $this->assertTrue($user->isRestricted()); // 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 with mode=mandate */ public function testSignupMandateMode(): void { Queue::fake(); $plan = Plan::create([ 'title' => 'test', 'name' => 'Test Account', 'description' => 'Test', 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0, 'mode' => Plan::MODE_MANDATE, ]); $packages = [ Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first() ]; $plan->packages()->saveMany($packages); $post = [ 'plan' => 'abc', 'login' => 'test-inv', 'domain' => 'kolabnow.com', 'password' => 'testtest', 'password_confirmation' => 'testtest', ]; // Test invalid plan identifier $response = $this->post('/api/auth/signup', $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The selected plan is invalid.", $json['errors']['plan']); // Test valid input $post['plan'] = $plan->title; $response = $this->post('/api/auth/signup', $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['access_token']); $this->assertSame('test-inv@kolabnow.com', $json['email']); $this->assertTrue($json['isLocked']); $user = User::where('email', 'test-inv@kolabnow.com')->first(); $this->assertNotEmpty($user); $this->assertSame($plan->id, $user->getSetting('plan_id')); } /** * Test signup via invitation */ - public function testSignupViaInvitation(): void + public function testSignupInvitation(): 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 } + /** + * Test signup validation (POST /signup/validate) + */ + public function testSignupValidate(): void + { + Queue::fake(); + + $plan = Plan::create([ + 'title' => 'test', + 'name' => 'Test Account', + 'description' => 'Test', + 'free_months' => 1, + 'months' => 12, + 'discount_qty' => 0, + 'discount_rate' => 0, + 'mode' => Plan::MODE_MANDATE, + ]); + + $packages = [ + Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first() + ]; + + $plan->packages()->saveMany($packages); + + $post = [ + 'login' => 'i', + 'password' => 'testtest', + 'password_confirmation' => 'testtest1', + 'voucher' => str_repeat('a', 33), + ]; + + // Test basic input validation + $response = $this->post('/api/auth/signup/validate', $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(4, $json['errors']); + $this->assertSame(["The login must be at least 2 characters."], $json['errors']['login']); + $this->assertSame(["The password confirmation does not match."], $json['errors']['password']); + $this->assertSame(["The domain field is required."], $json['errors']['domain']); + $this->assertSame(["The voucher may not be greater than 32 characters."], $json['errors']['voucher']); + + // Test with mode=mandate plan, but invalid voucher code + $post = [ + 'login' => 'test-inv', + 'domain' => 'kolabnow.com', + 'password' => 'testtest', + 'password_confirmation' => 'testtest', + 'plan' => $plan->title, + 'voucher' => 'non-existing', + ]; + + $response = $this->post('/api/auth/signup/validate', $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame("The voucher code is invalid or expired.", $json['errors']['voucher']); + + // Test with mode=mandate plan, and valid voucher code + $post['voucher'] = 'TEST'; + $response = $this->post('/api/auth/signup/validate', $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertStringContainsString('106,92 CHF', $json['content']); + + // TODO: Test other plan modes + } + /** * 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); } }