diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 1d26894f..72242150 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -12,12 +12,9 @@ use App\Models\Place; use App\Models\SyndicationTarget; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\MicropubHandlerRegistry; -use App\Services\Micropub\NoteResolver; -use App\Services\Micropub\NoteSource; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; class MicropubController extends Controller { @@ -49,15 +46,8 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); $dataClass = $handler->dataClass(); - - try { - /** @var MicropubData $data */ - $data = $dataClass::fromRequest($request); - } catch (\TypeError) { - // the data objects are typed, so this means a property has the wrong shape - return $this->invalidRequest('A property in the request has an invalid value'); - } - + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); $result = $handler->handle($data); if ($result['response'] === 'updated') { @@ -74,17 +64,31 @@ class MicropubController extends Controller return response()->json([ 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', - ], 403); + ], 401); } catch (ModelNotFoundException) { return response()->json([ 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) { + } catch (MicropubUnsupportedModelException $e) { + report($e); + + return response()->json([ + 'error' => 'invalid', + 'error_description' => 'This implementation currently only supports the updating of notes', + ], 500); + } catch (\InvalidArgumentException $e) { return response()->json([ 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 400); + } catch (MicropubHandlerException $e) { + report($e); + + return response()->json([ + 'error' => 'unsupported_operation', + 'error_description' => 'The request could not be processed by this server', + ], 500); } catch (\Throwable $e) { report($e); @@ -99,36 +103,40 @@ class MicropubController extends Controller * Respond to a GET request to the micropub endpoint. * * Token validation is handled by the VerifyMicropubToken middleware. - * Supports q=syndicate-to, q=config, q=source, and q=geo:, queries. + * Supports q=syndicate-to, q=config, and q=geo:, queries. * The default response returns the token metadata. */ public function get(Request $request): JsonResponse { - $q = $request->input('q'); - - if ($q === 'syndicate-to') { + if ($request->input('q') === 'syndicate-to') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), ]); } - if ($q === 'config') { + if ($request->input('q') === 'config') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), 'media-endpoint' => route('media-endpoint'), ]); } - if ($q === 'source') { - return $this->sourceResponse($request); - } + if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { + preg_match_all( + '/([0-9.\-]+)/', + $request->input('q'), + $matches + ); + $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; + $places = Place::near( + (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], + $distance + )->get(); - if (is_string($q) && str_starts_with($q, 'geo:')) { - return $this->placesResponse($q); - } - - if ($request->has('q')) { - return $this->invalidRequest('This server does not know how to handle this q parameter'); + return response()->json([ + 'response' => 'places', + 'places' => $places, + ]); } // the default response is just to return the token data @@ -144,59 +152,4 @@ class MicropubController extends Controller ], ]); } - - /** - * Respond to a q=source query with the microformats2 JSON of a post. - */ - private function sourceResponse(Request $request): JsonResponse - { - $url = $request->input('url'); - - if (! is_string($url)) { - return $this->invalidRequest('The url parameter is required'); - } - - try { - $note = resolve(NoteResolver::class)->fromUrl($url); - } catch (MicropubUnsupportedModelException $e) { - return $this->invalidRequest($e->getMessage()); - } catch (ModelNotFoundException) { - return $this->invalidRequest('No known note with given ID', 404); - } - - $requested = array_values(array_filter(Arr::wrap($request->input('properties')), 'is_string')); - - return response()->json(resolve(NoteSource::class)->toArray($note, $requested)); - } - - /** - * Respond to a q=geo:, query with the nearby places. - */ - private function placesResponse(string $q): JsonResponse - { - preg_match_all('/([0-9.\-]+)/', $q, $matches); - - if (count($matches[0]) < 2 || count(array_filter($matches[0], 'is_numeric')) !== count($matches[0])) { - return $this->invalidRequest('The geo query must contain a latitude and longitude'); - } - - $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; - $places = Place::near( - (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], - $distance - )->get(); - - return response()->json([ - 'response' => 'places', - 'places' => $places, - ]); - } - - private function invalidRequest(string $description, int $status = 400): JsonResponse - { - return response()->json([ - 'error' => 'invalid_request', - 'error_description' => $description, - ], $status); - } } diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index 7cbabc31..ab4fde74 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -15,36 +15,25 @@ class MicropubRequest extends FormRequest ]; } - /** - * Determine the request type: the action (e.g. "update") if one is given, - * otherwise the microformat type without its "h-" prefix. Per the spec, a - * request with no type is treated as an h-entry. - */ public function getType(): ?string { if ($this->isJson()) { $data = $this->json()->all(); - if (isset($data['action']) && is_string($data['action'])) { - return $data['action']; + if (isset($data['action']) && $data['action'] === 'update') { + return 'update'; } if (isset($data['type']) && is_array($data['type'])) { $type = current($data['type']); - if (is_string($type) && str_starts_with($type, 'h-')) { + if (str_starts_with($type, 'h-')) { return substr($type, 2); } - - return null; } - return 'entry'; + return null; } - if ($this->filled('action')) { - return $this->input('action'); - } - - return $this->input('h') ?: 'entry'; + return $this->input('h') ?: null; } } diff --git a/app/Http/Responses/MicropubResponses.php b/app/Http/Responses/MicropubResponses.php index d26dbf90..4f7240c2 100644 --- a/app/Http/Responses/MicropubResponses.php +++ b/app/Http/Responses/MicropubResponses.php @@ -17,7 +17,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'insufficient_scope', 'error_description' => 'The token’s scope does not have the necessary requirements.', - ], 403); + ], 401); } /** @@ -29,7 +29,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'invalid_token', 'error_description' => 'The provided token did not pass validation', - ], 401); + ], 400); } /** diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index 044127ce..192c50f7 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -25,7 +25,6 @@ class EntryData extends MicropubData public readonly mixed $syndication, public readonly ?array $photos, public readonly ?string $postStatus, - public readonly ?array $category, ) {} public static function fromRequest(Request $request): static @@ -52,7 +51,6 @@ class EntryData extends MicropubData syndication: Arr::get($data, 'properties.syndication.0'), photos: Arr::get($data, 'properties.photo'), postStatus: Arr::get($data, 'properties.post-status.0'), - category: Arr::get($data, 'properties.category'), ); } @@ -64,27 +62,17 @@ class EntryData extends MicropubData location: $request->input('location'), bookmarkOf: $request->input('bookmark-of'), likeOf: $request->input('like-of'), - mpSyndicateTo: self::wrapFormValue($request->input('mp-syndicate-to')), + mpSyndicateTo: $request->input('mp-syndicate-to'), name: $request->input('name'), description: $request->input('description'), geo: $request->input('geo'), checkin: $request->input('checkin'), syndication: $request->input('syndication'), - photos: self::wrapFormValue($request->input('photo')), + photos: $request->input('photos'), postStatus: $request->input('post-status'), - category: self::wrapFormValue($request->input('category')), ); } - /** - * Form-encoded multi-value properties may be sent with or without the [] - * suffix, so a single value arrives as a string rather than an array. - */ - private static function wrapFormValue(mixed $value): ?array - { - return $value === null ? null : Arr::wrap($value); - } - private static function extractLocationData(array $data): array|string|null { if (! Arr::has($data, 'properties.location')) { @@ -116,7 +104,6 @@ class EntryData extends MicropubData syndication: $data['syndication'] ?? null, photos: $data['photos'] ?? null, postStatus: $data['post-status'] ?? null, - category: $data['category'] ?? null, ); } @@ -138,7 +125,6 @@ class EntryData extends MicropubData 'syndication' => $this->syndication, 'photos' => $this->photos, 'post-status' => $this->postStatus, - 'category' => $this->category, ]; } } diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 0840e5ee..136a0840 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -11,7 +11,6 @@ use App\Models\Media; use App\Models\Note; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\Data\UpdateData; -use App\Services\Micropub\NoteResolver; use Illuminate\Support\Str; class UpdateHandler implements MicropubHandlerInterface @@ -37,18 +36,18 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - if ($data->updateUrl === null) { - throw new MicropubHandlerException('The url parameter is required'); + $urlPath = parse_url($data->updateUrl, PHP_URL_PATH); + + if (mb_substr($urlPath, 1, 5) !== 'notes') { + throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes'); } - $note = resolve(NoteResolver::class)->fromUrl($data->updateUrl); + $note = Note::nb60(basename($urlPath))->firstOrFail(); if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { throw new MicropubHandlerException('Unsupported update operation'); } - $this->validateOperations($data); - if ($data->updateReplace !== null) { foreach ($data->updateReplace as $property => $value) { match ($property) { @@ -90,66 +89,6 @@ class UpdateHandler implements MicropubHandlerInterface ]; } - /** - * Check the whole request before applying any of it, so that a malformed - * value can't leave a note partially updated. The spec requires the values - * of each property to be arrays. - * - * @throws MicropubHandlerException - */ - private function validateOperations(UpdateData $data): void - { - foreach ([$data->updateReplace ?? [], $data->updateAdd ?? []] as $operation) { - foreach ($operation as $property => $values) { - if (! is_string($property) || ! is_array($values) || $values === [] || ! array_is_list($values)) { - throw new MicropubHandlerException('The values of each property must be a non-empty array'); - } - - $this->validateValues($property, $values); - } - } - - $delete = $data->updateDelete ?? []; - - if (array_is_list($delete)) { - foreach ($delete as $property) { - if (! is_string($property)) { - throw new MicropubHandlerException('The properties to delete must be strings'); - } - } - - return; - } - - foreach ($delete as $property => $values) { - if (! is_array($values) || ! array_is_list($values)) { - throw new MicropubHandlerException('The values of each property must be an array'); - } - - $this->validateValues($property, $values); - } - } - - /** - * @throws MicropubHandlerException - */ - private function validateValues(string $property, array $values): void - { - if (! in_array($property, ['content', 'syndication', 'photo'], true)) { - return; - } - - foreach ($values as $value) { - if ($property === 'content' && is_array($value)) { - $value = $value['html'] ?? $value['value'] ?? null; - } - - if (! is_string($value)) { - throw new MicropubHandlerException("The {$property} property has an invalid value"); - } - } - } - private function applyDelete(Note $note, array $delete): void { if (array_is_list($delete)) { diff --git a/app/Services/Micropub/MicropubHandlerRegistry.php b/app/Services/Micropub/MicropubHandlerRegistry.php index e6438fa9..be393bf5 100644 --- a/app/Services/Micropub/MicropubHandlerRegistry.php +++ b/app/Services/Micropub/MicropubHandlerRegistry.php @@ -51,7 +51,7 @@ class MicropubHandlerRegistry public function getHandler(string $type): MicropubHandlerInterface { if (! isset($this->handlers[$type])) { - throw new MicropubHandlerException("Unsupported Micropub request type '{$type}'"); + throw new MicropubHandlerException("No handler registered for '{$type}'"); } return $this->handlers[$type]; diff --git a/app/Services/Micropub/NoteResolver.php b/app/Services/Micropub/NoteResolver.php deleted file mode 100644 index 26d8d39a..00000000 --- a/app/Services/Micropub/NoteResolver.php +++ /dev/null @@ -1,43 +0,0 @@ -firstOrFail(); - } catch (NotFoundHttpException) { - // Note::scopeNb60() aborts when the ID is too big for the database - throw (new ModelNotFoundException)->setModel(Note::class); - } - } -} diff --git a/app/Services/Micropub/NoteSource.php b/app/Services/Micropub/NoteSource.php deleted file mode 100644 index 457953f4..00000000 --- a/app/Services/Micropub/NoteSource.php +++ /dev/null @@ -1,69 +0,0 @@ -properties($note); - - if ($requested !== []) { - return ['properties' => (object) array_intersect_key($properties, array_flip($requested))]; - } - - return ['type' => ['h-entry'], 'properties' => (object) $properties]; - } - - private function properties(Note $note): array - { - $media = $note->media; - - return array_filter([ - // the raw source, as the note accessor returns the rendered HTML - 'content' => array_filter([$note->getRawOriginal('note')], fn ($value) => $value !== null), - 'published' => [$note->created_at->toIso8601String()], - 'in-reply-to' => array_filter([$note->in_reply_to]), - 'category' => $note->tags->pluck('tag')->all(), - 'photo' => $media->where('type', 'image')->map(fn ($item) => $item->pivot->alt_text - ? ['value' => $item->url, 'alt' => $item->pivot->alt_text] - : $item->url)->values()->all(), - 'video' => $media->where('type', 'video')->pluck('url')->all(), - 'audio' => $media->where('type', 'audio')->pluck('url')->all(), - 'syndication' => array_values(array_filter([ - $note->tweet_id ? 'https://twitter.com/i/web/status/'.$note->tweet_id : null, - $note->facebook_url, - $note->swarm_url, - $note->instagram_url, - $note->mastodon_url, - $note->bluesky_url, - ])), - 'location' => $this->location($note), - ], fn (array $values) => $values !== []); - } - - private function location(Note $note): array - { - if ($note->place !== null) { - return [$note->place->uri]; - } - - if ($note->getRawOriginal('location') !== null) { - return ['geo:'.$note->latitude.','.$note->longitude]; - } - - return []; - } -} diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index 87c4007f..2fbe45a2 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -30,9 +30,6 @@ class NoteService $content = null; } - // an invalid date throws, so parse it before anything is saved - $published = $this->getPublished($data); - $note = Note::create( [ 'note' => $content, @@ -41,7 +38,7 @@ class NoteService ] ); - if ($published !== null) { + if ($published = $this->getPublished($data)) { $note->created_at = $note->updated_at = $published; } @@ -208,11 +205,11 @@ class NoteService if (is_string($photo)) { $photoUrl = $photo; } elseif (is_array($photo)) { - $photoUrl = $photo['value'] ?? null; - $photoAlt = is_string($photo['alt'] ?? null) ? $photo['alt'] : null; + $photoUrl = $photo['value']; + $photoAlt = $photo['alt']; } - if (! is_string($photoUrl) || $photoUrl === '') { + if (empty($photoUrl)) { continue; } diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 5c4db0d8..fff9a267 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -116,26 +116,6 @@ class BookmarksTest extends TestCase $response->assertJson(['response' => 'created']); Queue::assertPushed(ProcessBookmark::class); - $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail(); - $this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all()); - } - - #[Test] - public function form_encoded_bookmark_with_categories_creates_tags(): void - { - Queue::fake(); - - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->getToken(), - ])->post('/api/post', [ - 'h' => 'entry', - 'bookmark-of' => 'https://example.org/blog-post', - 'category' => ['tag1', 'tag2'], - ]); - - $response->assertJson(['response' => 'created']); - - $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail(); - $this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all()); + $this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 60c5ec96..5ff59844 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; @@ -40,7 +41,7 @@ class MicropubControllerTest extends TestCase public function micropub_get_request_without_valid_token_returns_error_response(): void { $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer abc123']); - $response->assertStatus(401); + $response->assertStatus(400); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -202,7 +203,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ); - $response->assertStatus(401); + $response->assertStatus(400); $response->assertJson(['error' => 'invalid_token']); } @@ -232,7 +233,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(403); + $response->assertStatus(401); $response->assertJson(['error' => 'insufficient_scope']); } @@ -455,13 +456,16 @@ class MicropubControllerTest extends TestCase 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', ]) - ->assertStatus(403); + ->assertStatus(401); } #[Test] public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void { - $this->mock(ExceptionHandler::class)->shouldNotReceive('report'); + $this->mock(ExceptionHandler::class) + ->shouldReceive('report') + ->once() + ->with(\Mockery::type(MicropubHandlerException::class)); $response = $this->postJson( '/api/post', @@ -475,10 +479,10 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'invalid_request', - 'error_description' => "Unsupported Micropub request type 'unsupported'", + 'error' => 'unsupported_operation', + 'error_description' => 'The request could not be processed by this server', ]) - ->assertStatus(400); + ->assertStatus(500); } #[Test] @@ -633,8 +637,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'invalid_request']) - ->assertStatus(400); + ->assertJson(['error' => 'invalid']) + ->assertStatus(500); } #[Test] @@ -672,8 +676,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'invalid_request']) - ->assertStatus(400); + ->assertJson(['error' => 'unsupported_operation']) + ->assertStatus(500); } #[Test] @@ -691,7 +695,7 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); $response - ->assertStatus(403) + ->assertStatus(401) ->assertJson(['error' => 'insufficient_scope']); } @@ -1014,290 +1018,4 @@ class MicropubControllerTest extends TestCase ->assertStatus(400); $this->assertSame(1, Article::where('title', 'WireGuard')->count()); } - - #[Test] - public function micropub_client_web_request_without_h_property_creates_note(): void - { - $response = $this->post( - '/api/post', - ['content' => 'A note without an explicit type'], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertStatus(201)->assertJson(['response' => 'created']); - $this->assertDatabaseHas('notes', ['note' => 'A note without an explicit type']); - } - - #[Test] - public function micropub_client_api_request_without_type_creates_note(): void - { - $response = $this->postJson( - '/api/post', - ['properties' => ['content' => ['A JSON note without an explicit type']]], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertStatus(201)->assertJson(['response' => 'created']); - $this->assertDatabaseHas('notes', ['note' => 'A JSON note without an explicit type']); - } - - #[Test] - public function micropub_client_web_request_can_update_note(): void - { - $note = Note::factory()->create(); - - $response = $this->post( - '/api/post', - [ - 'action' => 'update', - 'url' => $note->uri, - 'replace' => ['content' => ['replaced via form']], - ], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertStatus(200)->assertJson(['response' => 'updated']); - $this->assertSame('replaced via form', $note->refresh()->content); - } - - #[Test] - public function micropub_client_web_request_with_unsupported_action_is_not_treated_as_a_new_note(): void - { - $note = Note::factory()->create(); - $noteCount = Note::count(); - - $response = $this->post( - '/api/post', - ['action' => 'delete', 'url' => $note->uri], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertStatus(400)->assertJson(['error' => 'invalid_request']); - $this->assertSame($noteCount, Note::count()); - } - - #[Test] - public function micropub_client_web_request_creates_note_with_photo(): void - { - $response = $this->post( - '/api/post', - [ - 'h' => 'entry', - 'content' => 'A note with a photo', - 'photo' => 'https://example.org/photo.jpg', - ], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertStatus(201); - $note = Note::where('note', 'A note with a photo')->firstOrFail(); - $this->assertSame(['https://example.org/photo.jpg'], $note->media->pluck('path')->all()); - } - - #[Test] - public function micropub_client_web_request_accepts_single_syndication_target_without_brackets(): void - { - Queue::fake(); - SyndicationTarget::factory()->create([ - 'uid' => 'https://mastodon.social/@jonnybarnes', - 'service_name' => 'Mastodon', - ]); - - $response = $this->post( - '/api/post', - [ - 'h' => 'entry', - 'content' => 'Syndicate me', - 'mp-syndicate-to' => 'https://mastodon.social/@jonnybarnes', - ], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertStatus(201); - Queue::assertPushed(SyndicateNoteToMastodon::class); - } - - #[Test] - public function source_query_returns_the_raw_note_as_microformats2(): void - { - $note = Note::factory()->create([ - 'note' => 'Some *markdown* #indieweb', - 'in_reply_to' => 'https://example.org/post', - 'mastodon_url' => 'https://mastodon.example/@me/1', - ]); - $media = Media::factory()->create(['path' => 'media/alt.jpg', 'type' => 'image']); - $note->media()->attach($media->id, ['alt_text' => 'A description', 'order' => 0]); - - $response = $this->getJson( - '/api/post?q=source&url='.urlencode($note->uri), - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertOk()->assertExactJson([ - 'type' => ['h-entry'], - 'properties' => [ - 'content' => ['Some *markdown* #indieweb'], - 'published' => [$note->created_at->toIso8601String()], - 'in-reply-to' => ['https://example.org/post'], - 'category' => ['indieweb'], - 'photo' => [['value' => $media->url, 'alt' => 'A description']], - 'syndication' => ['https://mastodon.example/@me/1'], - ], - ]); - } - - #[Test] - public function source_query_returns_the_location_of_a_note(): void - { - $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; - $geoNote = Note::factory()->create(['location' => '1.23, 4.56']); - $place = Place::factory()->create(); - $placeNote = Note::factory()->create(); - $placeNote->place()->associate($place)->save(); - - $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($geoNote->uri), $headers) - ->assertExactJson(['properties' => ['location' => ['geo:1.23,4.56']]]); - $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($placeNote->uri), $headers) - ->assertExactJson(['properties' => ['location' => [$place->uri]]]); - } - - #[Test] - public function source_query_only_returns_the_requested_properties(): void - { - $note = Note::factory()->create(['note' => 'Hello']); - - $response = $this->getJson( - '/api/post?q=source&properties[]=content&url='.urlencode($note->uri), - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $response->assertOk()->assertExactJson(['properties' => ['content' => ['Hello']]]); - } - - #[Test] - public function source_query_with_no_matching_properties_returns_an_empty_object(): void - { - $note = Note::factory()->create(); - - $response = $this->getJson( - '/api/post?q=source&properties[]=nonsense&url='.urlencode($note->uri), - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - ); - - $this->assertSame('{"properties":{}}', $response->assertOk()->getContent()); - } - - #[Test] - public function source_query_reports_invalid_urls(): void - { - $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; - $note = Note::factory()->create(); - - $this->getJson('/api/post?q=source', $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - $this->getJson('/api/post?q=source&url='.urlencode('https://other.example/notes/'.$note->nb60id), $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/blog/A'), $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/ZZZZ'), $headers) - ->assertStatus(404)->assertJson(['error' => 'invalid_request']); - $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/zzzzzzzzzz'), $headers) - ->assertStatus(404)->assertJson(['error' => 'invalid_request']); - } - - #[Test] - public function unknown_and_malformed_queries_return_an_error(): void - { - $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; - - foreach (['q=nonsense', 'q[]=config', 'q=geo:junk', 'q=geo:1.2.3,4'] as $query) { - $this->getJson('/api/post?'.$query, $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - } - } - - #[Test] - public function update_with_a_malformed_value_is_rejected_without_changing_the_note(): void - { - $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; - $note = Note::factory()->create(['note' => 'original']); - - $operations = [ - ['replace' => ['content' => 'hello']], - ['replace' => ['content' => []]], - ['replace' => ['content' => [123]]], - ['replace' => ['content' => ['replaced'], 'syndication' => 'https://www.swarmapp.com/1']], - ['replace' => ['content']], - ['add' => ['syndication' => [['nested']]]], - ['delete' => [['content']]], - ['delete' => ['syndication' => 'https://www.swarmapp.com/1']], - ]; - - foreach ($operations as $operation) { - $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - } - - $this->assertSame('original', $note->refresh()->getRawOriginal('note')); - } - - #[Test] - public function update_with_an_unusable_url_is_rejected(): void - { - $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; - $note = Note::factory()->create(['note' => 'original']); - $replace = ['replace' => ['content' => ['changed']]]; - - $this->postJson('/api/post', ['action' => 'update', ...$replace], $headers)->assertStatus(400); - $this->postJson('/api/post', ['action' => 'update', 'url' => ['a'], ...$replace], $headers)->assertStatus(400); - $this->postJson('/api/post', ['action' => 'update', 'url' => 'https://other.example/notes/'.$note->nb60id, ...$replace], $headers)->assertStatus(400); - $this->postJson('/api/post', ['action' => 'update', 'url' => config('app.url').'/notes/zzzzzzzzzz', ...$replace], $headers)->assertStatus(404); - - $this->assertSame('original', $note->refresh()->getRawOriginal('note')); - } - - #[Test] - public function creating_with_a_wrongly_shaped_property_returns_an_error(): void - { - $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; - $noteCount = Note::count(); - - $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'mp-syndicate-to' => 'https://a.example']], $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => [123]]], $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - $this->post('/api/post', ['h' => 'entry', 'content' => ['a', 'b']], $headers) - ->assertStatus(400)->assertJson(['error' => 'invalid_request']); - - $this->assertSame($noteCount, Note::count()); - } - - #[Test] - public function creating_with_an_invalid_published_date_does_not_leave_a_note_behind(): void - { - Queue::fake(); - $noteCount = Note::count(); - - $this->postJson( - '/api/post', - ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'published' => ['not a date']]], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - )->assertStatus(400)->assertJson(['error' => 'invalid_request']); - - $this->assertSame($noteCount, Note::count()); - } - - #[Test] - public function creating_with_a_photo_object_without_alt_text_succeeds(): void - { - Queue::fake(); - - $this->postJson( - '/api/post', - ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'photo' => [['value' => 'https://example.org/no-alt.jpg']]]], - ['HTTP_Authorization' => 'Bearer '.$this->getToken()] - )->assertStatus(201); - - $this->assertDatabaseHas('media_endpoint', ['path' => 'https://example.org/no-alt.jpg']); - } } diff --git a/tests/Feature/MicropubMediaTest.php b/tests/Feature/MicropubMediaTest.php index 96b6031c..ef4fcb05 100644 --- a/tests/Feature/MicropubMediaTest.php +++ b/tests/Feature/MicropubMediaTest.php @@ -41,7 +41,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(401); + $response->assertStatus(400); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -63,7 +63,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(403); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']); } @@ -210,14 +210,14 @@ class MicropubMediaTest extends TestCase } #[Test] - public function media_endpoint_request_with_invalid_token_returns401_response(): void + public function media_endpoint_request_with_invalid_token_returns400_response(): void { $response = $this->post( '/api/media', [], ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(401); + $response->assertStatus(400); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -234,14 +234,14 @@ class MicropubMediaTest extends TestCase } #[Test] - public function media_endpoint_request_with_insufficient_token_scopes_returns403_response(): void + public function media_endpoint_request_with_insufficient_token_scopes_returns401_response(): void { $response = $this->post( '/api/media', [], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(403); + $response->assertStatus(401); $response->assertJsonFragment([ 'error_description' => 'The token’s scope does not have the necessary requirements.', ]);