Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117779772
D5655.1775246343.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
34 KB
Referenced Files
None
Subscribers
None
D5655.1775246343.diff
View Options
diff --git a/src/app/Device.php b/src/app/Device.php
--- a/src/app/Device.php
+++ b/src/app/Device.php
@@ -58,93 +58,135 @@
// Before we do anything let's make sure that a device can be assigned only
// to a plan with a device SKU in a package
if ($device_packages->count() != 1) {
- throw new \Exception("A device requires a plan with a device SKU");
+ throw new \Exception("A plan with a device SKU is required");
}
foreach ($device_packages as $package) {
$this->assignPackageAndWallet($package, $wallet);
}
+
+ // Push entitlements.updated_at to one year in the future
+ $threshold = (clone $this->created_at)->addYearWithoutOverflow();
+ if ($threshold > \now()) {
+ $this->entitlements()->each(static function ($entitlement) use ($threshold) {
+ $entitlement->updated_at = $threshold;
+ $entitlement->save();
+ });
+ }
}
/**
* Assign device to (another) real user account
+ *
+ * @param string $token Device token
+ * @param User $user User claiming the device ownership
*/
- public function bindTo(User $user): void
+ public static function claim(string $token, User $user): self
{
- $wallet = $user->wallets()->first();
-
- // TODO: What if the device is already used by another (real) user?
- DB::beginTransaction();
-
- // Remove existing user association
- $this->entitlements()->delete();
-
- $device_packages = [];
+ $plan = null;
// Existing user's plan
if ($plan_id = $user->getSetting('plan_id')) {
$plan = Plan::withObjectTenantContext($user)->find($plan_id);
+ }
- // Find packages with a device SKU in this plan
- $device_packages = !$plan ? collect([]) : $plan->packages->filter(static function ($package) {
- foreach ($package->skus as $sku) {
- if ($sku->handler_class::entitleableClass() == self::class) {
- return true;
- }
- }
+ // TODO: Get "default" device package and assign if none found above, or just use the device SKU?
- return false;
- });
+ if (!$plan || !$plan->hasSku(self::class)) {
+ throw new \Exception("A plan with a device SKU is required");
+ }
- if ($device_packages->count() != 1) {
- throw new \Exception("A device requires a plan with a device SKU");
- }
+ DB::beginTransaction();
+
+ $device = self::initDevice($token);
+ $device->assignPlan($plan, $user->wallets()->first());
+
+ DB::commit();
+
+ return $device;
+ }
+
+ /**
+ * Get a default plan for a device signup
+ *
+ * @param string|SignupToken $token Device token
+ * @param bool $isDevice Get plan for a device, rather than a user
+ */
+ public static function defaultPlan($token, bool $isDevice): ?Plan
+ {
+ if (is_string($token)) {
+ $token = SignupToken::find(strtoupper($token));
}
- // TODO: Get "default" device package and assign if none found above, or just use the device SKU?
+ if (empty($token)) {
+ return null;
+ }
- foreach ($device_packages as $package) {
- $this->assignPackageAndWallet($package, $wallet);
+ $plans = Plan::withEnvTenantContext()
+ ->where('mode', Plan::MODE_TOKEN)
+ ->whereIn('id', $token->plans)
+ // TODO: Instead of using a prefix match, we should check the plan SKUs
+ ->whereLike('title', $isDevice ? 'device-%' : 'user-%')
+ ->get()
+ ->keyBy('title');
+
+ if (!$plans->count()) {
+ return null;
}
- // Push entitlements.updated_at to one year from the first registration
- $threshold = (clone $this->created_at)->addYearWithoutOverflow();
- if ($threshold > \now()) {
- $this->entitlements()->each(static function ($entitlement) use ($threshold) {
- $entitlement->updated_at = $threshold;
- $entitlement->save();
- });
+ $defaults = $plans->filter(function ($plan, $key) {
+ return str_contains($key, 'default');
+ });
+
+ if ($defaults->count()) {
+ return $defaults->first();
}
- DB::commit();
+ return $plans->first();
}
/**
- * Signup a device
+ * Setup a device record for the token. Create one if needed.
+ * Warning: It also removes any existing association with a user.
+ *
+ * @param string $token Device token
*/
- public static function signup(string $token, Plan $plan, string $password): self
+ public static function initDevice(string $token): self
{
- DB::beginTransaction();
-
// Check if a device already exists
$device = self::withTrashed()->where('hash', $token)->first();
if ($device) {
// FIXME: Should we remove the user (if it's a role=device user)?
- // FIXME: Should we bail out if the device is used by a normal user?
- // Remove the device-to-wallet connection
- $device->entitlements()->delete();
+ // Remove the existing device-to-wallet connection
+ $device->entitlements()->each(function ($entitlement) {
+ $entitlement->delete();
+ });
// Undelete the device if needed
if ($device->trashed()) {
- $device->restore();
+ $device->withoutEvents(function () use ($device) {
+ $device->restore();
+ });
}
} else {
// Create a device
$device = self::create(['hash' => $token]);
}
+ return $device;
+ }
+
+ /**
+ * Signup a device
+ */
+ public static function signup(string $token, Plan $plan, string $password): self
+ {
+ DB::beginTransaction();
+
+ $device = self::initDevice($token);
+
// Create a special account
while (true) {
$user_id = Utils::uuidInt();
@@ -166,21 +208,10 @@
]);
// Assign the device via an entitlement to the user's wallet
- $device->assignPlan($plan, $wallet = $user->wallets()->first());
-
- // Push entitlements.updated_at to one year in the future
- $threshold = (clone $device->created_at)->addYearWithoutOverflow();
- if ($threshold > \now()) {
- $device->entitlements()->each(static function ($entitlement) use ($threshold) {
- $entitlement->updated_at = $threshold;
- $entitlement->save();
- });
- }
+ $device->assignPlan($plan, $user->wallets()->first());
// FIXME: Should this bump signup_tokens.counter, as it does for user signup?
- // TODO: Trigger payment mandate creation?
-
DB::commit();
return $device;
diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php
--- a/src/app/Http/Controllers/API/PasswordResetController.php
+++ b/src/app/Http/Controllers/API/PasswordResetController.php
@@ -13,7 +13,6 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
-use Illuminate\Support\Str;
/**
* Password reset API
@@ -93,14 +92,10 @@
}
// Validate the verification code
- $code = VerificationCode::where('code', $request->code)->where('active', true)->first();
-
- if (
- empty($code)
- || $code->isExpired()
- || $code->mode !== VerificationCode::MODE_PASSWORD
- || Str::upper($request->short_code) !== Str::upper($code->short_code)
- ) {
+ $code = VerificationCode::where('code', $request->code)->where('active', true)
+ ->where('mode', VerificationCode::MODE_PASSWORD)->first();
+
+ if (empty($code) || !$code->codeValidate($request->short_code)) {
$errors = ['short_code' => self::trans('validation.verificationcodeinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -2,10 +2,12 @@
namespace App\Http\Controllers\API;
+use App\Device;
use App\Discount;
use App\Domain;
use App\Group;
use App\Http\Controllers\Controller;
+use App\Http\Resources\DeviceInfoResource;
use App\Http\Resources\PlanResource;
use App\Jobs\Mail\SignupVerificationJob;
use App\Payment;
@@ -252,7 +254,7 @@
#[BodyParameter('domain', description: 'User domain namespace', type: 'string', required: true)]
#[BodyParameter('password', description: 'User password', type: 'string', required: true)]
#[BodyParameter('voucher', description: 'Voucher code', type: 'string')]
- #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code)', type: 'string')]
+ #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code or token)', type: 'string')]
#[BodyParameter('invitation', description: 'Signup invitation identifier', type: 'string')]
#[BodyParameter('token', description: 'Signup token (required for mode=token plans)', type: 'string')]
#[BodyParameter('first_name', description: 'First name', type: 'string')]
@@ -287,6 +289,14 @@
// Direct signup by token
if ($request->token) {
+ if (!$request->plan) {
+ $plan = Device::defaultPlan($request->token, false);
+ if ($plan) {
+ $request->merge(['plan' => $plan]);
+ unset($rules['plan']);
+ }
+ }
+
// This will validate the token and the plan mode
$rules['token'] = ['required', 'string', new SignupTokenRule($plan)];
}
@@ -377,7 +387,7 @@
#[BodyParameter('domain', description: 'User domain namespace', type: 'string', required: true)]
#[BodyParameter('password', description: 'User password', type: 'string', required: true)]
#[BodyParameter('voucher', description: 'Voucher code', type: 'string')]
- #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code)', type: 'string')]
+ #[BodyParameter('plan', description: 'Plan identifier (required when not using a verification code or token)', type: 'string')]
#[BodyParameter('invitation', description: 'Signup invitation identifier', type: 'string')]
#[BodyParameter('token', description: 'Signup token (required for mode=token plans)', type: 'string')]
#[BodyParameter('first_name', description: 'First name', type: 'string')]
@@ -467,9 +477,17 @@
}
}
- // Bump up counter on the signup token
if (!empty($request->settings['signup_token'])) {
- SignupToken::where('id', $request->settings['signup_token'])->increment('counter');
+ $token = $request->settings['signup_token'];
+
+ // Bump up counter on the signup token
+ SignupToken::where('id', $token)->increment('counter');
+
+ // Bind the device with the user
+ if ($request->plan->hasSku(Device::class)) {
+ $device = Device::initDevice($token);
+ $device->assignPlan($request->plan, $user->wallets()->first());
+ }
}
DB::commit();
@@ -480,6 +498,10 @@
$response->checkout = $this->mandateForPlan($request->plan, $request->discount, $user);
}
+ if (!empty($device)) {
+ $response->device = new DeviceInfoResource($device);
+ }
+
return $response;
}
diff --git a/src/app/Http/Controllers/API/V4/DeviceController.php b/src/app/Http/Controllers/API/V4/DeviceController.php
--- a/src/app/Http/Controllers/API/V4/DeviceController.php
+++ b/src/app/Http/Controllers/API/V4/DeviceController.php
@@ -30,16 +30,31 @@
}
$device = Device::where('hash', strtoupper($token))->first();
-
- if (empty($device)) {
- return $this->errorResponse(404);
+ $user = $this->guard()->user();
+
+ if ($device) {
+ $device_wallet = $device->wallet();
+ $user_wallet = $user->wallets()->first();
+
+ // Does the existing device already belong to this user?
+ if ($device_wallet && $device_wallet->id == $user_wallet->id) {
+ response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.device-claim-success'),
+ ]);
+ }
+ } else {
+ if (!SignupToken::where('id', strtoupper($token))->exists()) {
+ return $this->errorResponse(404);
+ }
}
- $device->bindTo($this->guard()->user());
+ $device = Device::claim($token, $user);
return response()->json([
'status' => 'success',
'message' => self::trans('app.device-claim-success'),
+ 'device' => new DeviceInfoResource($device),
]);
}
@@ -106,38 +121,39 @@
*
* @unauthenticated
*/
- #[BodyParameter('plan', description: 'Plan title', type: 'string', required: true)]
+ #[BodyParameter('plan', description: 'Plan title', type: 'string')]
public function signup(Request $request, string $token)
{
- $v = Validator::make($request->all(), ['plan' => ['required', 'string']]);
-
- if ($v->fails()) {
- return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
- }
-
// Signup plan
- $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
+ if ($request->plan) {
+ $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
+ } else {
+ $plan = Device::defaultPlan($token, true);
+ }
if (!$plan) {
$errors = ['plan' => self::trans('validation.invalidvalue')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
- $request->merge([
- 'plan' => $plan,
- 'token' => \strtoupper($token),
- ]);
-
- // Validate input
- $v = Validator::make(
- $request->all(),
- [
- 'token' => ['required', 'string', new SignupTokenRule($plan)],
- ]
- );
-
- if ($v->fails()) {
- return response()->json(['status' => 'error', /* @var array */ 'errors' => $v->errors()], 422);
+ // Validate token (needed when using non-default plan)
+ if ($request->plan) {
+ $request->merge([
+ 'plan' => $plan,
+ 'token' => \strtoupper($token),
+ ]);
+
+ // Validate input
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'token' => ['required', 'string', new SignupTokenRule($plan)],
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', /* @var array */ 'errors' => $v->errors()], 422);
+ }
}
// TODO: Validate that the plan is device-only, don't accept a user plan here
@@ -157,6 +173,8 @@
'password' => $password,
];
+ $response->device = new DeviceInfoResource($device);
+
return $response;
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -82,12 +82,7 @@
return $this->errorResponse(403);
}
- if (
- empty($code)
- || $code->isExpired()
- || Str::upper($request->short_code) !== Str::upper($code->short_code)
- || empty($message = $code->applyAction())
- ) {
+ if (empty($code) || !$code->codeValidate($request->short_code, $message)) {
$errors = ['short_code' => self::trans('validation.verificationcodeinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
diff --git a/src/app/Http/Resources/AuthResource.php b/src/app/Http/Resources/AuthResource.php
--- a/src/app/Http/Resources/AuthResource.php
+++ b/src/app/Http/Resources/AuthResource.php
@@ -14,6 +14,7 @@
public ?int $user_id = null;
public ?array $checkout = null;
public ?array $credentials = null;
+ public ?DeviceInfoResource $device = null;
private ?UserInfoResource $userinfo = null;
@@ -50,6 +51,8 @@
'checkout' => $this->when(isset($this->checkout), $this->checkout),
// @var array New user credentials (on device signup)
'credentials' => $this->when(isset($this->credentials), $this->credentials),
+ // @var DeviceInfoResource Device information (on device signup)
+ 'device' => $this->when(isset($this->device), $this->device),
// @var int User identifier
'id' => $this->user_id,
// @var UserInfoResource User information
diff --git a/src/app/Http/Resources/DeviceInfoResource.php b/src/app/Http/Resources/DeviceInfoResource.php
--- a/src/app/Http/Resources/DeviceInfoResource.php
+++ b/src/app/Http/Resources/DeviceInfoResource.php
@@ -20,10 +20,26 @@
{
return [
/*
- * @var string Device registration date-time
+ * @var string Device registration date
* @format date-time
*/
'created_at' => (string) $this->resource->created_at,
+
+ // Number of free months left
+ 'freeMonths' => $this->freeMonths(),
];
}
+
+ /**
+ * Calculate number of free months left
+ */
+ private function freeMonths(): int
+ {
+ $until = (clone $this->created_at)->addYearWithoutOverflow()->floorMonth();
+ $now = (clone \now())->floorMonth();
+
+ $months = $now->diffInMonths($until);
+
+ return max(0, $months); // No negative values
+ }
}
diff --git a/src/app/Http/Resources/PlanResource.php b/src/app/Http/Resources/PlanResource.php
--- a/src/app/Http/Resources/PlanResource.php
+++ b/src/app/Http/Resources/PlanResource.php
@@ -24,6 +24,8 @@
}
return [
+ // @var string Plan identifier
+ 'id' => $this->resource->id,
// @var string Plan title (identifier)
'title' => $this->resource->title,
// @var string Plan name
diff --git a/src/app/Http/Resources/UserInfoResource.php b/src/app/Http/Resources/UserInfoResource.php
--- a/src/app/Http/Resources/UserInfoResource.php
+++ b/src/app/Http/Resources/UserInfoResource.php
@@ -24,6 +24,9 @@
'last_name',
'organization',
'phone',
+ 'password_expired',
+ 'debug',
+ 'plan_id',
];
/**
@@ -37,8 +40,7 @@
$isLocked = !$this->resource->isActive() && $wallet->plan()?->mode == Plan::MODE_MANDATE;
// Settings
- $keys = array_merge(self::USER_SETTINGS, ['password_expired', 'debug']);
- $settings = $this->resource->settings()->whereIn('key', $keys)->pluck('value', 'key')->all();
+ $settings = $this->resource->settings()->whereIn('key', self::USER_SETTINGS)->pluck('value', 'key')->all();
return [
$this->merge(parent::toArray($request)),
diff --git a/src/app/Plan.php b/src/app/Plan.php
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -134,4 +134,20 @@
return false;
}
+
+ /**
+ * Checks if the plan has a SKU assigned.
+ */
+ public function hasSku(string $class): bool
+ {
+ foreach ($this->packages as $package) {
+ foreach ($package->skus as $sku) {
+ if ($sku->handler_class::entitleableClass() == $class) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
}
diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php
--- a/src/app/SignupCode.php
+++ b/src/app/SignupCode.php
@@ -88,6 +88,12 @@
*/
public static function generateShortCode(): string
{
+ $test_code = \config('app.test_verification_code');
+
+ if (strlen($test_code)) {
+ return $test_code;
+ }
+
$code_length = env('SIGNUP_CODE_LENGTH', self::SHORTCODE_LENGTH);
return Utils::randStr($code_length);
diff --git a/src/app/VerificationCode.php b/src/app/VerificationCode.php
--- a/src/app/VerificationCode.php
+++ b/src/app/VerificationCode.php
@@ -55,14 +55,14 @@
/**
* Apply action on verified code.
*/
- public function applyAction(): ?string
+ protected function applyAction(&$message): bool
{
switch ($this->mode) {
case self::MODE_EMAIL:
$settings = $this->user->getSettings(['external_email_new', 'external_email_code']);
if ($settings['external_email_code'] != $this->code) {
- return null;
+ return false;
}
$this->user->setSettings([
@@ -72,11 +72,30 @@
]);
$this->delete();
+ $message = \trans('app.code-verified-email');
+ break;
+ }
+
+ return true;
+ }
- return \trans('app.code-verified-email');
- default:
- return null;
+ /**
+ * Validate a code and execute defined action if valid.
+ *
+ * @param string $short_code Short code
+ * @param ?string $message Success message
+ */
+ public function codeValidate(string $short_code, &$message = null): bool
+ {
+ if (!$this->active || $this->isExpired()) {
+ return false;
}
+
+ if (\strtoupper($short_code) !== \strtoupper($this->short_code)) {
+ return false;
+ }
+
+ return $this->applyAction($message);
}
/**
@@ -84,6 +103,12 @@
*/
public static function generateShortCode(): string
{
+ $test_code = \config('app.test_verification_code');
+
+ if (strlen($test_code)) {
+ return $test_code;
+ }
+
$code_length = env('VERIFICATION_CODE_LENGTH', self::SHORTCODE_LENGTH);
return Utils::randStr($code_length, 1, '', '1234567890');
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -288,4 +288,8 @@
['mail'],
\env('IMAP_WITH_GROUPWARE_DEFAULT_FOLDERS', true) ? ['event', 'contact', 'task', 'note', 'file'] : []
),
+
+ // Defines a short code that is used for every verification code
+ // Warning: Don't use it in production!
+ 'test_verification_code' => (string) env('TEST_VERIFICATION_CODE', ''),
];
diff --git a/src/tests/Feature/Controller/DeviceTest.php b/src/tests/Feature/Controller/DeviceTest.php
--- a/src/tests/Feature/Controller/DeviceTest.php
+++ b/src/tests/Feature/Controller/DeviceTest.php
@@ -24,7 +24,7 @@
$this->hash = str_repeat('1', 64);
Device::query()->forceDelete();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['device-test', 'device-test-default'])->delete();
Package::where('title', 'test')->delete();
User::where('role', User::ROLE_DEVICE)->forceDelete();
$this->deleteTestUser('jane@kolabnow.com');
@@ -34,7 +34,7 @@
protected function tearDown(): void
{
SignupToken::query()->delete();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['device-test', 'device-test-default'])->delete();
Package::where('title', 'test')->delete();
$this->deleteTestUser('jane@kolabnow.com');
User::where('role', User::ROLE_DEVICE)->forceDelete();
@@ -65,6 +65,28 @@
$json = $response->json();
$this->assertStringContainsString('2025-02-02', $json['created_at']);
+ $this->assertSame(12, $json['freeMonths']);
+
+ // Assert freeMonths after 2 months
+ Carbon::setTestNow(Carbon::createFromDate(2025, 4, 2));
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $json = $response->json();
+
+ $this->assertSame(10, $json['freeMonths']);
+
+ // Assert freeMonths after 12 months
+ Carbon::setTestNow(Carbon::createFromDate(2026, 2, 2));
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $json = $response->json();
+
+ $this->assertSame(0, $json['freeMonths']);
+
+ // Assert freeMonths after 13 months
+ Carbon::setTestNow(Carbon::createFromDate(2026, 3, 2));
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $json = $response->json();
+
+ $this->assertSame(0, $json['freeMonths']);
}
/**
@@ -95,6 +117,40 @@
$this->assertSame('success', $json['status']);
$this->assertSame('The device has been claimed successfully.', $json['message']);
+ $this->assertStringContainsString('2025-02-02', $json['device']['created_at']);
+ $this->assertSame(12, $json['device']['freeMonths']);
+ $device = Device::where('hash', $this->hash)->first();
+ $this->assertCount(1, $device->entitlements);
+ $this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
+
+ // Claim a soft-deleted device, no token registered
+ $device->delete();
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(404);
+
+ // Claim a soft-deleted device, token registered
+ SignupToken::create(['id' => $this->hash, 'plans' => [$plan->id]]);
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The device has been claimed successfully.', $json['message']);
+ $device = Device::where('hash', $this->hash)->first();
+ $this->assertCount(1, $device->entitlements);
+ $this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
+
+ // Claim a non-existing device
+ $device->forceDelete();
+ $response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The device has been claimed successfully.', $json['message']);
+ $device = Device::where('hash', $this->hash)->first();
$this->assertCount(1, $device->entitlements);
$this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
@@ -111,7 +167,7 @@
$response->assertStatus(404);
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'device-test',
'name' => 'Test',
'description' => 'Test',
'mode' => Plan::MODE_TOKEN,
@@ -138,7 +194,7 @@
{
$sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'device-test-default',
'name' => 'Test',
'description' => 'Test',
'mode' => Plan::MODE_TOKEN,
@@ -152,16 +208,6 @@
$plan->packages()->saveMany([$package]);
$package->skus()->saveMany([$sku]);
- // Signup, missing plan
- $post = [];
- $response = $this->post("api/v4/device/{$this->hash}/signup", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertSame(['plan' => ['The plan field is required.']], $json['errors']);
-
// Signup, invalid plan
$post = ['plan' => 'invalid'];
$response = $this->post("api/v4/device/{$this->hash}/signup", $post);
@@ -194,6 +240,8 @@
$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->assertStringContainsString('2025-02-02', $json['device']['created_at']);
+ $this->assertSame(12, $json['device']['freeMonths']);
$device = Device::where('hash', $this->hash)->first();
$account = $device->account;
@@ -215,7 +263,8 @@
// Note: without this finding the proper wallet may not work because of how Device::wallet() works
Carbon::setTestNow(Carbon::createFromDate(2025, 3, 4));
- // Signup again
+ // Signup again (w/o a plan now)
+ unset($post['plan']);
$response = $this->post("api/v4/device/{$this->hash}/signup", $post);
$response->assertStatus(200);
@@ -245,7 +294,7 @@
{
$sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'device-test',
'name' => 'Test',
'description' => 'Test',
'mode' => Plan::MODE_TOKEN,
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature\Controller;
+use App\Device;
use App\Discount;
use App\Domain;
use App\Http\Controllers\API\SignupController;
@@ -14,8 +15,10 @@
use App\SignupCode;
use App\SignupInvitation as SI;
use App\SignupToken;
+use App\Sku;
use App\User;
use App\VatRate;
+use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -43,7 +46,9 @@
SI::truncate();
SignupToken::truncate();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['test', 'user-test'])->delete();
+ Package::where('title', 'test')->delete();
+ Device::query()->forceDelete();
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
VatRate::query()->delete();
ReferralProgram::query()->delete();
@@ -63,7 +68,9 @@
SI::truncate();
SignupToken::truncate();
- Plan::where('title', 'test')->delete();
+ Plan::whereIn('title', ['test', 'user-test'])->delete();
+ Package::where('title', 'test')->delete();
+ Device::query()->forceDelete();
IP4Net::where('net_number', inet_pton('128.0.0.0'))->delete();
VatRate::query()->delete();
ReferralProgram::query()->delete();
@@ -994,8 +1001,9 @@
{
Queue::fake();
+ $sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
$plan = Plan::create([
- 'title' => 'test',
+ 'title' => 'user-test',
'name' => 'Test Account',
'description' => 'Test',
'free_months' => 1,
@@ -1003,6 +1011,16 @@
'discount_rate' => 0,
'mode' => Plan::MODE_TOKEN,
]);
+ $package = Package::create([
+ 'title' => 'test',
+ 'name' => 'Device Account',
+ 'description' => 'A device account.',
+ 'discount_rate' => 0,
+ ]);
+ $plan->packages()->saveMany([$package]);
+ $package->skus()->saveMany([$sku]);
+
+ Carbon::setTestNow(Carbon::createFromDate(2025, 2, 2));
$post = [
'plan' => $plan->title,
@@ -1023,7 +1041,6 @@
// Test valid token
$token = SignupToken::create(['id' => 'abc', 'plans' => [$plan->id]]);
- $post['plan'] = $plan->title;
$response = $this->post('/api/auth/signup', $post);
$response->assertStatus(200);
@@ -1041,6 +1058,45 @@
// Token's counter bumped up
$this->assertSame(1, $token->fresh()->counter);
+
+ // Device registered
+ $device = Device::where('hash', $token->id)->first();
+ $this->assertSame($device->account->email, $user->email);
+ $entitlements = $device->wallet()->entitlements()->get();
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($sku->id, $entitlements[0]->sku_id);
+ $this->assertStringContainsString('2026-02-02', $entitlements[0]->updated_at->toDateString());
+ $this->assertStringContainsString('2025-02-02', $json['device']['created_at']);
+ $this->assertSame(12, $json['device']['freeMonths']);
+
+ // Signup a new user with the same token/device (use default plan)
+ Carbon::setTestNow(Carbon::createFromDate(2025, 4, 2));
+ $post = [
+ 'token' => 'abc',
+ 'login' => 'signuplogin',
+ 'domain' => $this->domain,
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
+ ];
+
+ $response = $this->post('/api/auth/signup', $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $user2 = User::where('email', $post['login'] . '@' . $post['domain'])->first();
+ $this->assertSame($plan->id, $user->getSetting('plan_id'));
+ $this->assertSame($token->id, $user->getSetting('signup_token'));
+ $this->assertSame(2, $token->fresh()->counter);
+
+ $device->refresh();
+ $this->assertSame($device->account->email, $user2->email);
+ $entitlements = $device->entitlements()->get();
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($sku->id, $entitlements[0]->sku_id);
+ $this->assertStringContainsString('2026-02-02', $entitlements[0]->updated_at->toDateString());
+ $this->assertCount(1, $user->wallets()->first()->entitlements()->withTrashed()
+ ->where('sku_id', $sku->id)->whereNotNull('deleted_at')->get());
}
/**
diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php
--- a/src/tests/Feature/PlanTest.php
+++ b/src/tests/Feature/PlanTest.php
@@ -2,8 +2,10 @@
namespace Tests\Feature;
+use App\Domain;
use App\Plan;
use App\Tenant;
+use App\User;
use Tests\TestCase;
class PlanTest extends TestCase
@@ -74,6 +76,22 @@
$this->assertTrue($plan->hasDomain() === true);
}
+ /**
+ * Tests for Plan::hasSku()
+ */
+ public function testHasSku(): void
+ {
+ $plan = Plan::where('title', 'individual')->first();
+
+ $this->assertFalse($plan->hasSku(Domain::class));
+ $this->assertTrue($plan->hasSku(User::class));
+
+ $plan = Plan::where('title', 'group')->first();
+
+ $this->assertTrue($plan->hasSku(Domain::class));
+ $this->assertTrue($plan->hasSku(User::class));
+ }
+
/**
* Test for a plan's cost.
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 7:59 PM (11 h, 43 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18826201
Default Alt Text
D5655.1775246343.diff (34 KB)
Attached To
Mode
D5655: Device signup improvements and fixes
Attached
Detach File
Event Timeline