Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117793545
D5601.1775260432.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
29 KB
Referenced Files
None
Subscribers
None
D5601.1775260432.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
@@ -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
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 11:53 PM (16 h, 22 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18824627
Default Alt Text
D5601.1775260432.diff (29 KB)
Attached To
Mode
D5601: Device signup
Attached
Detach File
Event Timeline