Page MenuHomePhorge

No OneTemporary

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 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Jobs\SignupVerificationEmail;
use App\Discount;
use App\Domain;
use App\Plan;
use App\Providers\PaymentProvider;
use App\Rules\SignupExternalEmail;
use App\Rules\SignupToken;
use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
use App\SignupInvitation;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/**
* Signup process API
*/
class SignupController extends Controller
{
/**
* Returns plans definitions for signup.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function plans(Request $request)
{
// Use reverse order just to have individual on left, group on right ;)
// But prefer monthly on left, yearly on right
$plans = Plan::withEnvTenantContext()->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 = '<b>' . self::trans('app.signup-account-tobecreated') . '</b><br><br>'
. self::trans('app.signup-account-summary', $params) . '<br><br>'
. 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 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\SignupController;
use App\Discount;
use App\Domain;
use App\Plan;
use App\Package;
use App\SignupCode;
use App\SignupInvitation as SI;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class SignupTest extends TestCase
{
private $domain;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// TODO: Some tests depend on existence of individual and group plans,
// we should probably create plans here to not depend on that
$this->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);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, May 16, 2:27 AM (8 h, 56 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
178345
Default Alt Text
(56 KB)

Event Timeline