Page MenuHomePhorge

D5601.1775332824.diff
No OneTemporary

Authored By
Unknown
Size
29 KB
Referenced Files
None
Subscribers
None

D5601.1775332824.diff

diff --git a/src/app/Device.php b/src/app/Device.php
--- a/src/app/Device.php
+++ b/src/app/Device.php
@@ -2,7 +2,6 @@
namespace App;
-use App\Http\Resources\PlanResource;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\UuidIntKeyTrait;
@@ -36,61 +35,130 @@
'hash',
];
+ /**
+ * Assign a package plan to a device.
+ *
+ * @param Plan $plan The plan to assign
+ * @param Wallet $wallet The wallet to use
+ *
+ * @throws \Exception
+ */
+ public function assignPlan(Plan $plan, Wallet $wallet): void
+ {
+ $device_packages = $plan->packages->filter(static function ($package) {
+ foreach ($package->skus as $sku) {
+ if ($sku->handler_class::entitleableClass() == self::class) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ // 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");
+ }
+
+ foreach ($device_packages as $package) {
+ $this->assignPackageAndWallet($package, $wallet);
+ }
+ }
+
/**
* Assign device to (another) real user account
*/
- public function bindTo(User $user)
+ public function bindTo(User $user): void
{
$wallet = $user->wallets()->first();
// TODO: What if the device is already used by another (real) user?
+ DB::beginTransaction();
- $this->entitlements()->update(['wallet_id' => $wallet->id]);
+ // Remove existing user association
+ $this->entitlements()->delete();
- // TODO: Update created_at/updated_at accordingly
- // TODO: Delete the dummy user record?
- }
+ $device_packages = [];
- /**
- * Get device information
- */
- public function info(): array
- {
- $plans = Plan::withObjectTenantContext($this)->where('mode', 'token')
- ->orderByDesc('months')->orderByDesc('title')
- ->get();
+ // 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;
+ }
+ }
+
+ return false;
+ });
+
+ if ($device_packages->count() != 1) {
+ throw new \Exception("A device requires a plan with a device SKU");
+ }
+ }
- $result = [
- // Device registration date-time
- 'created_at' => (string) $this->created_at,
- // Plans available for signup via a device token
- 'plans' => PlanResource::collection($plans),
- ];
+ // TODO: Get "default" device package and assign if none found above, or just use the device SKU?
- // TODO: Include other information about the plan/wallet/payments state
+ foreach ($device_packages as $package) {
+ $this->assignPackageAndWallet($package, $wallet);
+ }
- return $result;
+ // 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();
+ });
+ }
+
+ DB::commit();
}
/**
- * Register a device
+ * Signup a device
*/
- public static function register(string $hash): self
+ public static function signup(string $token, Plan $plan, string $password): self
{
DB::beginTransaction();
// Create a device record
- $device = self::create(['hash' => $hash]);
-
- // Create a special account (and wallet)
- $user = User::create([
- 'email' => $device->id . '@' . \config('app.domain'),
- 'password' => '',
- 'role' => User::ROLE_DEVICE,
+ $device = self::create(['hash' => $token]);
+
+ // Create a special account
+ while (true) {
+ $user_id = Utils::uuidInt();
+ if (!User::withTrashed()->where('id', $user_id)->orWhere('email', $user_id . '@' . \config('app.domain'))->exists()) {
+ break;
+ }
+ }
+
+ $user = new User();
+ $user->id = $user_id;
+ $user->email = $user_id . '@' . \config('app.domain');
+ $user->password = $password;
+ $user->role = User::ROLE_DEVICE;
+ $user->save();
+
+ $user->settings()->insert([
+ ['key' => 'signup_token', 'value' => $token, 'user_id' => $user->id],
+ ['key' => 'plan_id', 'value' => $plan->id, 'user_id' => $user->id],
]);
- // Assign an entitlement
- $device->assignToWallet($user->wallets()->first(), 'device');
+ // 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
+ $device->entitlements()->each(static function ($entitlement) {
+ $entitlement->updated_at = \now()->addYearWithoutOverflow();
+ $entitlement->save();
+ });
+
+ // TODO: Trigger payment mandate creation?
DB::commit();
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -26,11 +26,9 @@
*
* Note that the same information is by default included in the `auth/login` response.
*/
- public function info(): JsonResponse
+ public function info()
{
- $response = new UserInfoResource($this->guard()->user());
-
- return $response->response();
+ return new UserInfoResource($this->guard()->user());
}
/**
@@ -41,7 +39,7 @@
* @param string $password Plain text password
* @param string|null $secondFactor Second factor code if available
*/
- public static function logonResponse(User $user, string $password, ?string $secondFactor = null): JsonResponse
+ public static function logonResponse(User $user, string $password, ?string $secondFactor = null)
{
$mode = request()->mode; // have to be before we make a request below
@@ -69,7 +67,7 @@
*
* @unauthenticated
*/
- public function login(Request $request): JsonResponse
+ public function login(Request $request)
{
$v = Validator::make(
$request->all(),
@@ -175,7 +173,7 @@
/**
* Refresh a session token.
*/
- public function refresh(Request $request): JsonResponse
+ public function refresh(Request $request)
{
$v = Validator::make($request->all(), [
// Request user information in the response
@@ -209,7 +207,7 @@
* @param ?User $user The user being authenticated
* @param ?bool $mode Response mode: 'fast' - return minimum set of user data
*/
- protected static function respondWithToken($tokenResponse, $user = null, $mode = null): JsonResponse
+ protected static function respondWithToken($tokenResponse, $user = null, $mode = null)
{
$data = json_decode($tokenResponse->getContent());
@@ -247,6 +245,6 @@
}
}
- return $response->response();
+ return $response;
}
}
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
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
+use App\Http\Resources\AuthResource;
use App\Jobs\Mail\PasswordResetJob;
use App\Rules\Password;
use App\User;
@@ -124,7 +125,7 @@
* @unauthenticated
*/
#[BodyParameter('secondfactor', description: '2FA token (required if user enabled 2FA)', type: 'string')]
- public function reset(Request $request): JsonResponse
+ public function reset(Request $request)
{
$v = $this->verify($request);
if ($v->status() !== 200) {
@@ -158,7 +159,7 @@
*/
#[BodyParameter('email', description: 'User email address', type: 'string', required: true)]
#[BodyParameter('secondfactor', description: '2FA token (required if user enabled 2FA)', type: 'string')]
- public function resetExpired(Request $request): JsonResponse
+ public function resetExpired(Request $request)
{
$user = User::where('email', $request->email)->first();
@@ -269,7 +270,7 @@
// could be possibly a bit simpler (no need for a DB transaction).
$response = AuthController::logonResponse($user, $request->password, $request->secondfactor);
- if ($response->status() == 200) {
+ if ($response instanceof AuthResource) {
// Remove the verification code
if ($request->code instanceof VerificationCode) {
$request->code->delete();
@@ -278,9 +279,7 @@
DB::commit();
// Add confirmation message to the 'success' response
- $data = $response->getData(true);
- $data['message'] = self::trans('app.password-reset-success');
- $response->setData($data);
+ $response->message = self::trans('app.password-reset-success');
} else {
// If authentication failed (2FA or geo-lock), revert the password change
DB::rollBack();
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
@@ -382,7 +382,7 @@
#[BodyParameter('token', description: 'Signup token (required for mode=token plans)', type: 'string')]
#[BodyParameter('first_name', description: 'First name', type: 'string')]
#[BodyParameter('last_name', description: 'Last name', type: 'string')]
- public function signup(Request $request): JsonResponse
+ public function signup(Request $request)
{
$v = $this->signupValidate($request);
if ($v->status() !== 200) {
@@ -477,10 +477,7 @@
$response = AuthController::logonResponse($user, $request->password);
if ($request->plan->mode == Plan::MODE_MANDATE) {
- $data = $response->getData(true);
- // TODO: Make it visible in the API Doc
- $data['checkout'] = $this->mandateForPlan($request->plan, $request->discount, $user);
- $response->setData($data);
+ $response->checkout = $this->mandateForPlan($request->plan, $request->discount, $user);
}
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
@@ -3,24 +3,33 @@
namespace App\Http\Controllers\API\V4;
use App\Device;
+use App\Http\Controllers\API\AuthController;
use App\Http\Controllers\Controller;
+use App\Http\Resources\DeviceInfoResource;
+use App\Http\Resources\PlanResource;
+use App\Plan;
+use App\Rules\SignupToken as SignupTokenRule;
use App\SignupToken;
+use App\Utils;
+use Dedoc\Scramble\Attributes\BodyParameter;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
class DeviceController extends Controller
{
/**
- * User claims the device ownership.
+ * Claim a device.
*
- * @param string $hash Device secret identifier
+ * @param string $token Device secret token
*/
- public function claim(string $hash): JsonResponse
+ public function claim(string $token): JsonResponse
{
- if (strlen($hash) > 191) {
+ if (strlen($token) > 191) {
return $this->errorResponse(404);
}
- $device = Device::where('hash', strtoupper($hash))->first();
+ $device = Device::where('hash', strtoupper($token))->first();
if (empty($device)) {
return $this->errorResponse(404);
@@ -35,32 +44,124 @@
}
/**
- * Get the device information.
+ * Device information.
*
- * @param string $hash Device secret identifier
+ * @param string $token Device secret token
*
* @unauthenticated
*/
- public function info(string $hash): JsonResponse
+ public function info(string $token)
{
- if (strlen($hash) > 191) {
+ if (strlen($token) > 191) {
return $this->errorResponse(404);
}
- $device = Device::where('hash', $hash)->first();
+ $device = Device::where('hash', $token)->first();
- // Register a device
if (!$device) {
- // Only possible if a signup token exists?
- if (!SignupToken::where('id', strtoupper($hash))->exists()) {
- return $this->errorResponse(404);
- }
+ return $this->errorResponse(404);
+ }
- $device = Device::register($hash);
+ return new DeviceInfoResource($device);
+ }
+
+ /**
+ * List signup plans.
+ *
+ * @param string $token Device secret token
+ *
+ * @unauthenticated
+ */
+ public function plans(string $token): JsonResponse
+ {
+ if (strlen($token) > 191) {
+ return $this->errorResponse(404);
}
- $response = $device->info();
+ $token = SignupToken::where('id', strtoupper($token))->first();
+
+ if (empty($token)) {
+ return $this->errorResponse(404);
+ }
+
+ // TODO: Return plans specified in the token
+ $plans = Plan::withEnvTenantContext()->where('mode', Plan::MODE_TOKEN)
+ ->orderByDesc('months')->orderByDesc('title')
+ ->get();
+
+ return response()->json([
+ // List of signup plans
+ 'list' => PlanResource::collection($plans),
+ // @var int Number of entries in the list
+ 'count' => count($plans),
+ // @var bool Indicates that there are more entries available
+ 'hasMore' => false,
+ ]);
+ }
+
+ /**
+ * Signup a device.
+ *
+ * @param string $token Device secret token
+ *
+ * @unauthenticated
+ */
+ #[BodyParameter('plan', description: 'Plan title', type: 'string', required: true)]
+ 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 (!$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);
+ }
+
+ // TODO: Validate that the plan is device-only, don't accept a user plan here
+
+ // Check if a device already exists
+ if (Device::withTrashed()->where('hash', $token)->exists()) {
+ return $this->errorResponse(500);
+ }
+
+ // TODO: Should we get the password from the device? Then we'd not have to return it back at the end
+ $password = Utils::generatePassphrase();
+
+ // Register a device
+ $device = Device::signup($token, $plan, $password);
+
+ // Auto-login the user (same as we do on a normal user signup)
+ $response = AuthController::logonResponse($device->account, $password);
+
+ // Let the device know email+password so it can use the API
+ $response->credentials = [
+ 'email' => $device->account->email,
+ 'password' => $password,
+ ];
- return response()->json($response);
+ return $response;
}
}
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
@@ -11,7 +11,10 @@
class AuthResource extends JsonResource
{
public string $status = 'success';
+ public ?string $message = null;
public ?int $user_id = null;
+ public ?array $checkout = null;
+ public ?array $credentials = null;
private ?UserInfoResource $userinfo = null;
@@ -42,6 +45,12 @@
'expires_in' => (int) $this->resource->expires_in,
// Response status
'status' => $this->status,
+ // @var string Response message
+ 'message' => $this->when(isset($this->message), $this->message),
+ // @var array Payment checkout information (on signup)
+ '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 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
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Resources/DeviceInfoResource.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Http\Resources;
+
+use App\Device;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+/**
+ * Device response
+ *
+ * @mixin Device
+ */
+class DeviceInfoResource extends JsonResource
+{
+ /**
+ * Transform the resource into an array.
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ // Device registration date-time
+ 'created_at' => (string) $this->resource->created_at,
+ ];
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -738,7 +738,7 @@
*/
public function setRoleAttribute($role)
{
- if ($role !== null && !in_array($role, [self::ROLE_ADMIN, self::ROLE_RESELLER, self::ROLE_SERVICE])) {
+ if ($role !== null && !in_array($role, [self::ROLE_ADMIN, self::ROLE_RESELLER, self::ROLE_SERVICE, self::ROLE_DEVICE])) {
throw new \Exception("Invalid role: {$role}");
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -197,8 +197,12 @@
Route::get('config/webmail', [API\V4\ConfigController::class, 'webmail']);
- Route::post('device/{hash}/claim', [API\V4\DeviceController::class, 'claim']);
- Route::get('device/{hash}', [API\V4\DeviceController::class, 'info'])
+ Route::post('device/{token}/claim', [API\V4\DeviceController::class, 'claim']);
+ Route::get('device/{token}', [API\V4\DeviceController::class, 'info'])
+ ->withoutMiddleware(['auth:api', 'scope:api']);
+ Route::get('device/{token}/plans', [API\V4\DeviceController::class, 'plans'])
+ ->withoutMiddleware(['auth:api', 'scope:api']);
+ Route::post('device/{token}/signup', [API\V4\DeviceController::class, 'signup'])
->withoutMiddleware(['auth:api', 'scope:api']);
Route::apiResource('domains', API\V4\DomainsController::class);
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
@@ -3,10 +3,12 @@
namespace Tests\Feature\Controller;
use App\Device;
+use App\Package;
use App\Plan;
use App\SignupToken;
use App\Sku;
use App\User;
+use Carbon\Carbon;
use Tests\TestCase;
class DeviceTest extends TestCase
@@ -16,63 +18,53 @@
protected function setUp(): void
{
parent::setUp();
+
+ Carbon::setTestNow(Carbon::createFromDate(2025, 2, 2));
+
$this->hash = str_repeat('1', 64);
- SignupToken::query()->delete();
Device::query()->forceDelete();
+ Plan::where('title', 'test')->delete();
+ Package::where('title', 'test')->delete();
User::where('role', User::ROLE_DEVICE)->forceDelete();
$this->deleteTestUser('jane@kolabnow.com');
+ SignupToken::query()->delete();
}
protected function tearDown(): void
{
SignupToken::query()->delete();
- Device::query()->forceDelete();
- User::where('role', User::ROLE_DEVICE)->forceDelete();
+ Plan::where('title', 'test')->delete();
+ Package::where('title', 'test')->delete();
$this->deleteTestUser('jane@kolabnow.com');
+ User::where('role', User::ROLE_DEVICE)->forceDelete();
+ Device::query()->forceDelete();
parent::tearDown();
}
/**
- * Test device registration and info (GET /api/v4/device/<hash>)
+ * Test device info (GET /api/v4/device/<token>)
*/
public function testInfo(): void
{
- // Unknown hash (invalid hash)
+ // Unknown hash (invalid token)
$response = $this->get('api/v4/device/unknown');
$response->assertStatus(404);
- // Unknown hash (valid hash)
+ // Unknown hash (valid token)
$response = $this->get('api/v4/device/' . $this->hash);
$response->assertStatus(404);
- $sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
- $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
- $plan->signupTokens()->create(['id' => $this->hash]);
-
- // First registration
- $response = $this->get('api/v4/device/' . $this->hash);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertStringContainsString(date('Y-m-d'), $json['created_at']);
- $this->assertSame([], $json['plans']);
-
- $device = Device::where('hash', $this->hash)->first();
- $this->assertTrue(!empty($device));
- $entitlements = $device->wallet()->entitlements()->get();
- $this->assertCount(1, $entitlements);
- $this->assertSame($sku->id, $entitlements[0]->sku_id);
-
// Getting info of a registered device
+ [$device, $plan] = $this->initTestDevice();
+
$response = $this->get('api/v4/device/' . $this->hash);
$response->assertStatus(200);
$json = $response->json();
- $this->assertStringContainsString(date('Y-m-d'), $json['created_at']);
+ $this->assertStringContainsString('2025-02-02', $json['created_at']);
}
/**
@@ -90,7 +82,10 @@
$response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
$response->assertStatus(404);
- $device = Device::register($this->hash);
+ [$device, $plan] = $this->initTestDevice();
+ $user->setSetting('plan_id', $plan->id);
+
+ $this->assertTrue($user->id != $device->account->id);
// Claim an existing device
$response = $this->actingAs($user)->post('api/v4/device/' . $this->hash . '/claim', []);
@@ -102,5 +97,137 @@
$this->assertSame('The device has been claimed successfully.', $json['message']);
$this->assertCount(1, $device->entitlements);
$this->assertSame($user->wallets()->first()->id, $device->entitlements[0]->wallet_id);
+
+ // TODO: Test case when a claimant does not have a plan, or it does not include a device SKU
+ }
+
+ /**
+ * Test device signup plans (GET /api/v4/device/<token>/plans)
+ */
+ public function testPlans(): void
+ {
+ // Unknown hash (valid token)
+ $response = $this->get('api/v4/device/' . $this->hash);
+ $response->assertStatus(404);
+
+ $plan = Plan::create([
+ 'title' => 'test',
+ 'name' => 'Test',
+ 'description' => 'Test',
+ 'mode' => Plan::MODE_TOKEN,
+ ]);
+
+ $plan->signupTokens()->create(['id' => $this->hash]);
+
+ // Getting list of plans
+ $response = $this->get("api/v4/device/{$this->hash}/plans");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertFalse($json['hasMore']);
+ $this->assertSame($plan->title, $json['list'][0]['title']);
+ }
+
+ /**
+ * Test device registration (POST /api/v4/device/<token>/signup)
+ */
+ public function testSignup(): void
+ {
+ $sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
+ $plan = Plan::create([
+ 'title' => 'test',
+ 'name' => 'Test',
+ 'description' => 'Test',
+ '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]);
+
+ // 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);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(['plan' => 'Invalid value'], $json['errors']);
+
+ // Signup, valid plan, but unknown token
+ $post = ['plan' => $plan->title];
+ $response = $this->post("api/v4/device/{$this->hash}/signup", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(['token' => ['The signup token is invalid.']], $json['errors']);
+
+ $plan->signupTokens()->create(['id' => $this->hash]);
+
+ // Signup success
+ $response = $this->post("api/v4/device/{$this->hash}/signup", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $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']);
+
+ $device = Device::where('hash', $this->hash)->first();
+
+ $this->assertTrue(!empty($device));
+ $this->assertSame($device->account->email, $json['user']['email']);
+ $this->assertSame(User::ROLE_DEVICE, $device->account->role);
+ $this->assertSame($plan->id, $device->account->getSetting('plan_id'));
+ $this->assertSame($this->hash, $device->account->getSetting('signup_token'));
+
+ $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());
+ }
+
+ private function initTestDevice(): array
+ {
+ $sku = Sku::withEnvTenantContext()->where('title', 'device')->first();
+ $plan = Plan::create([
+ 'title' => 'test',
+ 'name' => 'Test',
+ 'description' => 'Test',
+ '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]);
+
+ $device = Device::signup($this->hash, $plan, 'simple123');
+
+ return [$device, $plan, $package, $sku];
}
}

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 8:00 PM (20 h, 16 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18824627
Default Alt Text
D5601.1775332824.diff (29 KB)

Event Timeline