mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-07 16:18:33 +00:00
Let users change their profile and avatar with their token
This commit is contained in:
3 files changed
+348
No files matched your search
@@ -397,6 +397,32 @@ class AnikeenId
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a multipart/form-data request (file uploads), with the same headers and error handling
|
||||
* as request(), which stays JSON only.
|
||||
*
|
||||
* @param list<array{name: string, contents: mixed, filename?: string, headers?: array<string, string>}> $multipart
|
||||
* Guzzle's multipart parts
|
||||
* @throws GuzzleException
|
||||
* @throws RequestRequiresClientIdException
|
||||
*/
|
||||
public function requestMultipart(string $method, string $path, array $multipart): Result
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request($method, $path, [
|
||||
// no Content-Type: Guzzle sets it with the boundary
|
||||
'headers' => $this->buildHeaders(),
|
||||
'multipart' => $multipart,
|
||||
]);
|
||||
|
||||
$result = new Result($response, null, $this);
|
||||
} catch (RequestException $exception) {
|
||||
$result = new Result($exception->getResponse(), $exception, $this);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for request.
|
||||
*
|
||||
|
||||
@@ -2,8 +2,19 @@
|
||||
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||
use Anikeen\Id\Resources\User;
|
||||
use Anikeen\Id\Result;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use SplFileInfo;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The user's own profile, with the user's token. Changes need the scope `user` (`user:read`
|
||||
* only reads; 403 "missing_scope").
|
||||
*/
|
||||
trait ManagesProfile
|
||||
{
|
||||
use HasBillable;
|
||||
@@ -23,4 +34,99 @@ trait ManagesProfile
|
||||
'options' => $options,
|
||||
])->data->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the user's profile (scope `user`). Only the fields sent change. A new email address
|
||||
* doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes
|
||||
* the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the
|
||||
* result; user.updated follows then). Clears the cached getUserData().
|
||||
*
|
||||
* @param array{
|
||||
* name?: string,
|
||||
* username?: string,
|
||||
* email?: string
|
||||
* } $attributes The fields to change
|
||||
* - name: The display name
|
||||
* - username: The username (unique regardless of case)
|
||||
* - email: A new email address (lower-cased; needs the user's confirmation)
|
||||
* @return User the user as `GET v1/user` returns them, plus `pending_email` and
|
||||
* `pending_email_expires_at`
|
||||
* @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken",
|
||||
* "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field))
|
||||
* @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required",
|
||||
* 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour)
|
||||
*/
|
||||
public function updateProfile(array $attributes): User
|
||||
{
|
||||
$anikeenId = $this->anikeenId();
|
||||
|
||||
return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to
|
||||
* 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears
|
||||
* the cached getUserData().
|
||||
*
|
||||
* @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile
|
||||
* @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the
|
||||
* upright picture; the centre without one
|
||||
* @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large",
|
||||
* "crop_invalid"
|
||||
* @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required",
|
||||
* 429 "too_many_requests" (also more than 10 uploads an hour)
|
||||
*/
|
||||
public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User
|
||||
{
|
||||
$path = $file instanceof SplFileInfo ? $file->getPathname() : $file;
|
||||
$filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName')
|
||||
? $file->getClientOriginalName()
|
||||
: basename($path);
|
||||
|
||||
$multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]];
|
||||
|
||||
foreach (['x', 'y', 'size'] as $key) {
|
||||
if (isset($crop[$key])) {
|
||||
$multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]];
|
||||
}
|
||||
}
|
||||
|
||||
$anikeenId = $this->anikeenId();
|
||||
|
||||
return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the uploaded profile picture (scope `user`); the initials are shown again when it
|
||||
* was the one shown. Clears the cached getUserData().
|
||||
*
|
||||
* @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests"
|
||||
*/
|
||||
public function removeAvatar(): User
|
||||
{
|
||||
$anikeenId = $this->anikeenId();
|
||||
|
||||
return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(): Result $send
|
||||
* @throws ApiException
|
||||
*/
|
||||
private function sendProfileRequest(callable $send): User
|
||||
{
|
||||
try {
|
||||
$result = $send();
|
||||
} catch (GuzzleException $e) {
|
||||
throw ApiException::unreachable($e);
|
||||
} finally {
|
||||
$this->userDataCache = null;
|
||||
}
|
||||
|
||||
if (!$result->success()) {
|
||||
throw ApiException::fromResult($result);
|
||||
}
|
||||
|
||||
return new User($result->data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\Billable;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||
use DateTimeImmutable;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserProfileTest extends TestCase
|
||||
{
|
||||
private function owner(): Model
|
||||
{
|
||||
$owner = new class extends Model {
|
||||
use Billable;
|
||||
|
||||
protected $guarded = [];
|
||||
};
|
||||
$owner->anikeen_id = '01JUSER';
|
||||
$owner->anikeen_id_access_token = 'user-token';
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
private function sentCall(int $index): string
|
||||
{
|
||||
$request = $this->sent[$index]['request'];
|
||||
|
||||
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
|
||||
}
|
||||
|
||||
private function profile(array $overrides = []): array
|
||||
{
|
||||
return ['data' => [
|
||||
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
|
||||
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
|
||||
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
|
||||
...$overrides,
|
||||
]];
|
||||
}
|
||||
|
||||
private function validationError(array $codes): array
|
||||
{
|
||||
return [
|
||||
'message' => 'The given data was invalid.',
|
||||
'error' => array_values($codes)[0],
|
||||
'errors' => array_map(fn () => ['The value is invalid.'], $codes),
|
||||
'codes' => $codes,
|
||||
];
|
||||
}
|
||||
|
||||
public function test_update_profile_patches_the_fields_with_the_user_token(): void
|
||||
{
|
||||
$this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9,
|
||||
'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z']));
|
||||
|
||||
$user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']);
|
||||
|
||||
$this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0));
|
||||
$this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0));
|
||||
$this->assertSame('Ada Lovelace', $user->name());
|
||||
$this->assertSame('ada@example.com', $user->email());
|
||||
$this->assertSame('new@example.com', $user->pendingEmail());
|
||||
$this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt());
|
||||
$this->assertSame(9, $user->version());
|
||||
}
|
||||
|
||||
public function test_update_profile_clears_the_cached_user_data(): void
|
||||
{
|
||||
$owner = $this->owner();
|
||||
$this->respond(200, $this->profile());
|
||||
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
|
||||
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
|
||||
|
||||
$this->assertSame('Ada King', $owner->getUserData()->name);
|
||||
$owner->updateProfile(['name' => 'Ada Lovelace']);
|
||||
|
||||
$this->assertSame('Ada Lovelace', $owner->getUserData()->name);
|
||||
$this->assertCount(3, $this->sent);
|
||||
$this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2));
|
||||
}
|
||||
|
||||
public function test_refused_profile_changes_carry_codes_per_field(): void
|
||||
{
|
||||
$this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid']));
|
||||
|
||||
try {
|
||||
$this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']);
|
||||
$this->fail('No exception thrown');
|
||||
} catch (ValidationFailedException $e) {
|
||||
$this->assertSame(422, $e->status);
|
||||
$this->assertSame('username_taken', $e->error);
|
||||
$this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes());
|
||||
$this->assertSame('email_invalid', $e->errorCodeFor('email'));
|
||||
$this->assertNull($e->errorCodeFor('name'));
|
||||
$this->assertTrue($e->hasErrorCode('email_invalid'));
|
||||
$this->assertFalse($e->hasErrorCode('email_taken'));
|
||||
$this->assertSame(['The value is invalid.'], $e->errors()['username']);
|
||||
$this->assertInstanceOf(ApiException::class, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_a_client_credentials_token_is_refused(): void
|
||||
{
|
||||
$this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']);
|
||||
|
||||
try {
|
||||
$this->owner()->updateProfile(['name' => 'Ada']);
|
||||
$this->fail('No exception thrown');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertNotInstanceOf(ValidationFailedException::class, $e);
|
||||
$this->assertSame('user_token_required', $e->error);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_an_unreachable_service_is_an_api_exception(): void
|
||||
{
|
||||
$this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile')));
|
||||
|
||||
try {
|
||||
$this->owner()->updateProfile(['name' => 'Ada']);
|
||||
$this->fail('No exception thrown');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertSame(0, $e->status);
|
||||
$this->assertTrue($e->isTemporary());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_upload_avatar_sends_the_picture_as_multipart(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||
file_put_contents($path, 'picture-bytes');
|
||||
$this->respond(200, $this->profile(['profile_version' => 10]));
|
||||
|
||||
try {
|
||||
$user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]);
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
$request = $this->sent[0]['request'];
|
||||
$body = (string) $request->getBody();
|
||||
|
||||
$this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0));
|
||||
$this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type'));
|
||||
$this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body);
|
||||
$this->assertStringContainsString('picture-bytes', $body);
|
||||
$this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body);
|
||||
$this->assertStringContainsString('name="crop[y]"', $body);
|
||||
$this->assertStringContainsString('name="crop[size]"', $body);
|
||||
$this->assertSame(10, $user->version());
|
||||
}
|
||||
|
||||
public function test_upload_avatar_without_crop_sends_only_the_picture(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||
file_put_contents($path, 'picture-bytes');
|
||||
$this->respond(200, $this->profile());
|
||||
|
||||
try {
|
||||
$this->owner()->uploadAvatar(new \SplFileInfo($path));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
$body = (string) $this->sent[0]['request']->getBody();
|
||||
$this->assertStringContainsString('name="avatar"', $body);
|
||||
$this->assertStringNotContainsString('crop', $body);
|
||||
}
|
||||
|
||||
public function test_a_refused_upload_carries_its_code(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||
file_put_contents($path, 'not a picture');
|
||||
$this->respond(422, $this->validationError(['avatar' => 'avatar_invalid']));
|
||||
$this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']);
|
||||
|
||||
try {
|
||||
try {
|
||||
$this->owner()->uploadAvatar($path);
|
||||
$this->fail('No exception thrown');
|
||||
} catch (ValidationFailedException $e) {
|
||||
$this->assertSame('avatar_invalid', $e->errorCodeFor('avatar'));
|
||||
}
|
||||
|
||||
try {
|
||||
$this->owner()->uploadAvatar($path);
|
||||
$this->fail('No exception thrown');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertSame(413, $e->status);
|
||||
$this->assertSame('avatar_too_large', $e->error);
|
||||
}
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void
|
||||
{
|
||||
$owner = $this->owner();
|
||||
$this->respond(200, $this->profile());
|
||||
$this->respond(200, $this->profile(['profile_version' => 11]));
|
||||
$this->respond(200, $this->profile(['profile_version' => 11]));
|
||||
|
||||
$owner->getUserData();
|
||||
$user = $owner->removeAvatar();
|
||||
$owner->getUserData();
|
||||
|
||||
$this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1));
|
||||
$this->assertSame(11, $user->version());
|
||||
$this->assertCount(3, $this->sent);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user