Skip to content

Commit 2935935

Browse files
committed
Restore separate API endpoint so clients can use both as appropriate
1 parent cfe50a1 commit 2935935

4 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers;
6+
7+
use App\Http\Requests\StoreDeviceRequest;
8+
use App\Http\Resources\Device as DeviceResource;
9+
use App\Models\Device;
10+
use App\Models\User;
11+
use Illuminate\Http\JsonResponse;
12+
use Illuminate\Routing\Controllers\HasMiddleware;
13+
use Illuminate\Routing\Controllers\Middleware;
14+
15+
class DeviceController implements HasMiddleware
16+
{
17+
#[\Override]
18+
public static function middleware(): array
19+
{
20+
return [
21+
new Middleware('permission:create-attendance', only: ['inventory']),
22+
];
23+
}
24+
25+
/**
26+
* Create or update a Device from an inventory report.
27+
*/
28+
public function inventory(StoreDeviceRequest $request): JsonResponse
29+
{
30+
$user = $request->user();
31+
32+
if (! $user instanceof User) {
33+
return response()->json([
34+
'status' => 'error',
35+
'message' => 'A user token is required.',
36+
], 401);
37+
}
38+
39+
$ipAddress = $request->ip();
40+
41+
if ($ipAddress === null) {
42+
return response()->json([
43+
'status' => 'error',
44+
'message' => 'last_seen_ip_address is required.',
45+
], 422);
46+
}
47+
48+
$validated = $request->validated();
49+
50+
$device = Device::updateOrCreate(
51+
['serial_number' => $validated['serial_number']],
52+
array_merge(
53+
$validated,
54+
[
55+
'last_seen_user_id' => $user->id,
56+
'last_seen_at' => now(),
57+
'last_seen_ip_address' => $ipAddress,
58+
]
59+
)
60+
);
61+
62+
$code = $device->wasRecentlyCreated ? 201 : 200;
63+
64+
return response()->json(
65+
[
66+
'status' => 'success',
67+
'device' => new DeviceResource($device),
68+
],
69+
$code
70+
);
71+
}
72+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Requests;
6+
7+
use Illuminate\Foundation\Http\FormRequest;
8+
9+
class StoreDeviceRequest extends FormRequest
10+
{
11+
/**
12+
* Determine if the user is authorized to make this request.
13+
*
14+
* @psalm-pure
15+
*/
16+
public function authorize(): bool
17+
{
18+
return true;
19+
}
20+
21+
/**
22+
* Get the validation rules that apply to the request.
23+
*
24+
* @return array<string, array<string>>
25+
*
26+
* @psalm-pure
27+
*/
28+
public function rules(): array
29+
{
30+
return [
31+
'serial_number' => [
32+
'required',
33+
'digits:7',
34+
],
35+
'hardware_version' => [
36+
'required',
37+
'string',
38+
'max:255',
39+
],
40+
'software_version' => [
41+
'required',
42+
'string',
43+
'max:255',
44+
],
45+
'firmware_version' => [
46+
'required',
47+
'string',
48+
'max:255',
49+
],
50+
'battery_percentage' => [
51+
'required',
52+
'integer',
53+
'between:0,100',
54+
],
55+
];
56+
}
57+
}

routes/api.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
use App\Http\Controllers\AttendanceController;
6+
use App\Http\Controllers\DeviceController;
67
use App\Http\Controllers\DuesPackageController;
78
use App\Http\Controllers\DuesTransactionController;
89
use App\Http\Controllers\EventController;
@@ -59,6 +60,9 @@ static function (): void {
5960
Route::post('attendance/search', [AttendanceController::class, 'search'])->name('attendance.search');
6061
Route::get('attendance/statistics', [AttendanceController::class, 'statistics'])->name('attendance.statistics');
6162

63+
// Devices
64+
Route::post('devices/inventory', [DeviceController::class, 'inventory'])->name('devices.inventory');
65+
6266
// Users
6367
// The search endpoint MUST be registered before the apiResource, otherwise it will not take precedence
6468
Route::get('users/search', [UserController::class, 'search']);
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature;
6+
7+
use App\Models\Device;
8+
use Illuminate\Testing\Fluent\AssertableJson;
9+
use Laravel\Passport\ClientRepository;
10+
use Laravel\Passport\Passport;
11+
use Tests\TestCase;
12+
13+
final class DeviceControllerTest extends TestCase
14+
{
15+
public function test_unauthenticated_requests_are_rejected(): void
16+
{
17+
$response = $this->postJson('/api/v1/devices/inventory', []);
18+
19+
$response->assertStatus(401);
20+
}
21+
22+
public function test_user_without_create_attendance_cannot_inventory_device(): void
23+
{
24+
$user = $this->getTestUser(['non-member']);
25+
26+
$response = $this
27+
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
28+
->actingAs($user, 'api')
29+
->postJson('/api/v1/devices/inventory', [
30+
'serial_number' => '1234567',
31+
'hardware_version' => '1.0',
32+
'software_version' => '2.0',
33+
'firmware_version' => '3.0',
34+
]);
35+
36+
$response->assertStatus(403);
37+
}
38+
39+
public function test_creates_device_with_last_seen_metadata(): void
40+
{
41+
$user = $this->getTestUser(['shared-device']);
42+
43+
$response = $this
44+
->withServerVariables(['REMOTE_ADDR' => '192.168.1.50'])
45+
->actingAs($user, 'api')
46+
->postJson('/api/v1/devices/inventory', [
47+
'serial_number' => '1234567',
48+
'hardware_version' => 'hw-1',
49+
'software_version' => 'sw-1',
50+
'firmware_version' => 'fw-1',
51+
'battery_percentage' => 75,
52+
]);
53+
54+
$response->assertStatus(201);
55+
$response->assertJson(static function (AssertableJson $json) use ($user): void {
56+
$json->where('status', 'success')
57+
->has('device', static function (AssertableJson $json) use ($user): void {
58+
$json->where('serial_number', 1234567)
59+
->where('hardware_version', 'hw-1')
60+
->where('software_version', 'sw-1')
61+
->where('firmware_version', 'fw-1')
62+
->where('battery_percentage', 75)
63+
->where('last_seen_user_id', (string) $user->id)
64+
->where('last_seen_ip_address', '192.168.1.50')
65+
->has('last_seen_at')
66+
->etc();
67+
});
68+
});
69+
70+
$this->assertDatabaseHas('devices', [
71+
'serial_number' => 1234567,
72+
'hardware_version' => 'hw-1',
73+
'software_version' => 'sw-1',
74+
'firmware_version' => 'fw-1',
75+
'battery_percentage' => 75,
76+
'last_seen_user_id' => $user->id,
77+
'last_seen_ip_address' => '192.168.1.50',
78+
]);
79+
}
80+
81+
public function test_updates_existing_device_and_metadata(): void
82+
{
83+
$originalUser = $this->getTestUser(['non-member']);
84+
$device = Device::factory()->create([
85+
'serial_number' => 7654321,
86+
'hardware_version' => 'old-hw',
87+
'software_version' => 'old-sw',
88+
'firmware_version' => 'old-fw',
89+
'battery_percentage' => 88,
90+
'last_seen_user_id' => $originalUser->id,
91+
'last_seen_ip_address' => '10.0.0.1',
92+
]);
93+
94+
$user = $this->getTestUser(['shared-device'], 'apiarytestingshared');
95+
96+
$response = $this
97+
->withServerVariables(['REMOTE_ADDR' => '10.0.0.2'])
98+
->actingAs($user, 'api')
99+
->postJson('/api/v1/devices/inventory', [
100+
'serial_number' => '7654321',
101+
'hardware_version' => 'new-hw',
102+
'software_version' => 'new-sw',
103+
'firmware_version' => 'new-fw',
104+
'battery_percentage' => 88,
105+
]);
106+
107+
$response->assertStatus(200);
108+
$response->assertJson(static function (AssertableJson $json) use ($user): void {
109+
$json->where('status', 'success')
110+
->has('device', static function (AssertableJson $json) use ($user): void {
111+
$json->where('serial_number', 7654321)
112+
->where('hardware_version', 'new-hw')
113+
->where('software_version', 'new-sw')
114+
->where('firmware_version', 'new-fw')
115+
->where('battery_percentage', 88)
116+
->where('last_seen_user_id', (string) $user->id)
117+
->where('last_seen_ip_address', '10.0.0.2')
118+
->etc();
119+
});
120+
});
121+
122+
$device->refresh();
123+
$this->assertSame('new-hw', $device->hardware_version);
124+
$this->assertSame(88, $device->battery_percentage);
125+
$this->assertSame($user->id, $device->last_seen_user_id);
126+
$this->assertSame('10.0.0.2', $device->last_seen_ip_address);
127+
}
128+
129+
public function test_missing_battery_percentage_fails_validation(): void
130+
{
131+
$user = $this->getTestUser(['shared-device']);
132+
133+
$response = $this
134+
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
135+
->actingAs($user, 'api')
136+
->postJson('/api/v1/devices/inventory', [
137+
'serial_number' => '1234567',
138+
'hardware_version' => 'hw-1',
139+
'software_version' => 'sw-1',
140+
'firmware_version' => 'fw-1',
141+
]);
142+
143+
$response->assertStatus(422);
144+
$response->assertInvalid(['battery_percentage']);
145+
}
146+
147+
public function test_invalid_serial_number_fails_validation(): void
148+
{
149+
$user = $this->getTestUser(['shared-device']);
150+
151+
$response = $this
152+
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
153+
->actingAs($user, 'api')
154+
->postJson('/api/v1/devices/inventory', [
155+
'serial_number' => '12345',
156+
'hardware_version' => 'hw-1',
157+
'software_version' => 'sw-1',
158+
'firmware_version' => 'fw-1',
159+
'battery_percentage' => 75,
160+
]);
161+
162+
$response->assertStatus(422);
163+
$response->assertInvalid(['serial_number']);
164+
}
165+
166+
public function test_missing_required_versions_fails_validation(): void
167+
{
168+
$user = $this->getTestUser(['shared-device']);
169+
170+
$response = $this
171+
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
172+
->actingAs($user, 'api')
173+
->postJson('/api/v1/devices/inventory', [
174+
'serial_number' => '1234567',
175+
'battery_percentage' => 75,
176+
]);
177+
178+
$response->assertStatus(422);
179+
$response->assertInvalid(['hardware_version', 'software_version', 'firmware_version']);
180+
}
181+
182+
public function test_battery_percentage_out_of_range_fails_validation(): void
183+
{
184+
$user = $this->getTestUser(['shared-device']);
185+
186+
$response = $this
187+
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
188+
->actingAs($user, 'api')
189+
->postJson('/api/v1/devices/inventory', [
190+
'serial_number' => '1234567',
191+
'hardware_version' => 'hw-1',
192+
'software_version' => 'sw-1',
193+
'firmware_version' => 'fw-1',
194+
'battery_percentage' => 101,
195+
]);
196+
197+
$response->assertStatus(422);
198+
$response->assertInvalid(['battery_percentage']);
199+
}
200+
201+
public function test_client_token_with_permission_is_rejected(): void
202+
{
203+
$clientRepository = new ClientRepository();
204+
$client = $clientRepository->createClientCredentialsGrantClient(name: 'test-device');
205+
$client->givePermissionTo('create-attendance');
206+
207+
Passport::actingAsClient($client);
208+
209+
$response = $this
210+
->withToken('test')
211+
->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
212+
->postJson('/api/v1/devices/inventory', [
213+
'serial_number' => '1234567',
214+
'hardware_version' => 'hw-1',
215+
'software_version' => 'sw-1',
216+
'firmware_version' => 'fw-1',
217+
'battery_percentage' => 75,
218+
]);
219+
220+
$response->assertStatus(401);
221+
}
222+
223+
public function test_missing_request_ip_fails(): void
224+
{
225+
$user = $this->getTestUser(['shared-device']);
226+
227+
$response = $this
228+
->withServerVariables(['REMOTE_ADDR' => null])
229+
->actingAs($user, 'api')
230+
->postJson('/api/v1/devices/inventory', [
231+
'serial_number' => '1234567',
232+
'hardware_version' => 'hw-1',
233+
'software_version' => 'sw-1',
234+
'firmware_version' => 'fw-1',
235+
'battery_percentage' => 75,
236+
]);
237+
238+
$response->assertStatus(422);
239+
$response->assertJson(static function (AssertableJson $json): void {
240+
$json->where('status', 'error')
241+
->where('message', 'last_seen_ip_address is required.');
242+
});
243+
}
244+
}

0 commit comments

Comments
 (0)