Let users change their profile and avatar with their token

This commit is contained in:
envoyr committed 2026-11-07 00:33:41 +00:00
1 parent 2eceb34faa
commit 93f6d79a0a
3 files changed
+348

No files matched your search

+26
View File
@@ -397,6 +397,32 @@ class AnikeenId
return $result; 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. * Build headers for request.
* *
+106
View File
@@ -2,8 +2,19 @@
namespace Anikeen\Id\Concerns; 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; 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 trait ManagesProfile
{ {
use HasBillable; use HasBillable;
@@ -23,4 +34,99 @@ trait ManagesProfile
'options' => $options, 'options' => $options,
])->data->url; ])->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);
}
} }
+216
View File
@@ -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);
}
}