jonnybarnes.uk/tests/Feature/MicropubControllerTest.php
Jonny Barnes 3dce7d3a60
Add Micropub q=source and reject malformed requests
Servers supporting updates must support the source content query. It
was falling through to the token response, so clients got a 200 with no
post in it. Return the raw note as microformats2 JSON, honouring
properties[].

Malformed requests could cause a 500 or worse. A replace value sent as a
string overwrote the note with its first character, because $value[0]
indexes the string. Validate update operations before applying any of
them, and return invalid_request for wrongly shaped properties, unknown
or malformed q values, and missing update URLs.

Share URL resolution between updates and q=source in NoteResolver, which
also rejects URLs on other hosts and invalid note IDs. Those IDs used to
decode to a real note because invalid characters were skipped.

Allow photo objects without alt text, and parse the published date
before saving so that an invalid date doesn't leave a stray note behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:59:23 +00:00

1303 lines
44 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Article;
use App\Models\Bookmark;
use App\Models\Like;
use App\Models\Media;
use App\Models\Note;
use App\Models\Place;
use App\Models\SyndicationTarget;
use Faker\Factory;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
use Tests\TestToken;
class MicropubControllerTest extends TestCase
{
use RefreshDatabase;
use TestToken;
#[Test]
public function micropub_get_request_without_token_returns_error_response(): void
{
$response = $this->get('/api/post');
$response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'No access token was provided in the request']);
}
#[Test]
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->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
}
/**
* Test a GET request for the micropub endpoint with a valid token gives a
* 200 response. Check token information is also returned in the response.
*/
#[Test]
public function micropub_get_request_with_valid_token_returns_ok_response(): void
{
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$this->getToken()]);
$response->assertStatus(200);
$response->assertJsonFragment(['response' => 'token']);
}
#[Test]
public function micropub_clients_can_request_syndication_targets_can_be_empty(): void
{
$response = $this->get('/api/post?q=syndicate-to', ['HTTP_Authorization' => 'Bearer '.$this->getToken()]);
$response->assertJsonFragment(['syndicate-to' => []]);
}
#[Test]
public function micropub_clients_can_request_syndication_targets_populates_from_model(): void
{
$syndicationTarget = SyndicationTarget::factory()->create();
$response = $this->get('/api/post?q=syndicate-to', ['HTTP_Authorization' => 'Bearer '.$this->getToken()]);
$response->assertJsonFragment(['uid' => $syndicationTarget->uid]);
}
#[Test]
public function micropub_clients_can_request_known_nearby_places(): void
{
Place::factory()->create([
'name' => 'The Bridgewater Pub',
'latitude' => '53.5',
'longitude' => '-2.38',
]);
$response = $this->get('/api/post?q=geo:53.5,-2.38', ['HTTP_Authorization' => 'Bearer '.$this->getToken()]);
$response->assertJson(['places' => [['slug' => 'the-bridgewater-pub']]]);
}
/**
* @todo Add uncertainty parameter
*
public function micropubClientsCanRequestKnownNearbyPlacesWithUncertaintyParameter(): void
{
$response = $this->get('/api/post?q=geo:53.5,-2.38', ['HTTP_Authorization' => 'Bearer ' . $this->getToken()]);
$response->assertJson(['places' => [['slug' => 'the-bridgewater-pub']]]);
}*/
#[Test]
public function return_empty_result_when_micropub_client_requests_known_nearby_places(): void
{
$response = $this->get('/api/post?q=geo:1.23,4.56', ['HTTP_Authorization' => 'Bearer '.$this->getToken()]);
$response->assertJson(['places' => []]);
}
#[Test]
public function micropub_client_can_request_endpoint_config(): void
{
$response = $this->get('/api/post?q=config', ['HTTP_Authorization' => 'Bearer '.$this->getToken()]);
$response->assertJsonFragment(['media-endpoint' => route('media-endpoint')]);
}
#[Test]
public function micropub_client_can_create_new_note(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => $note,
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => $note]);
}
#[Test]
public function micropub_client_can_request_the_new_note_is_syndicated_to_mastodon_and_bluesky(): void
{
Queue::fake();
SyndicationTarget::factory()->create([
'uid' => 'https://mastodon.social/@jonnybarnes',
'service_name' => 'Mastodon',
]);
SyndicationTarget::factory()->create([
'uid' => 'https://bsky.app/profile/jonnybarnes.uk',
'service_name' => 'Bluesky',
]);
$faker = Factory::create();
$note = $faker->text;
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => $note,
'mp-syndicate-to' => [
'https://mastodon.social/@jonnybarnes',
'https://bsky.app/profile/jonnybarnes.uk',
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => $note]);
Queue::assertPushed(SyndicateNoteToMastodon::class);
Queue::assertPushed(SyndicateNoteToBluesky::class);
}
#[Test]
public function micropub_clients_can_create_new_places(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'card',
'name' => 'The Barton Arms',
'geo' => 'geo:53.4974,-2.3768',
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertJson(['response' => 'created']);
$this->assertDatabaseHas('places', ['slug' => 'the-barton-arms']);
}
#[Test]
public function micropub_clients_can_create_new_places_with_old_location_syntax(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'card',
'name' => 'The Barton Arms',
'latitude' => '53.4974',
'longitude' => '-2.3768',
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertJson(['response' => 'created']);
$this->assertDatabaseHas('places', ['slug' => 'the-barton-arms']);
}
#[Test]
public function micropub_client_web_request_with_invalid_token_returns_error_response(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'A random note',
],
['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()]
);
$response->assertStatus(401);
$response->assertJson(['error' => 'invalid_token']);
}
#[Test]
public function micropub_client_web_request_with_token_without_any_scopes_returns_error_response(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'A random note',
],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithNoScope()]
);
$response->assertStatus(400);
$response->assertJson(['error_description' => 'The provided token has no scopes']);
}
#[Test]
public function micropub_client_web_request_with_token_without_create_scopes_returns_error_response(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'A random note',
],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response->assertStatus(403);
$response->assertJson(['error' => 'insufficient_scope']);
}
/**
* Test a valid micropub requests using JSON syntax creates a new note.
*/
#[Test]
public function micropub_client_api_request_creates_new_note_with_photo(): void
{
Queue::fake();
Media::create([
'path' => 'media/test-photo.jpg',
'type' => 'image',
]);
SyndicationTarget::factory()->create([
'uid' => 'https://mastodon.social/@jonnybarnes',
'service_name' => 'Mastodon',
]);
SyndicationTarget::factory()->create([
'uid' => 'https://bsky.app/profile/jonnybarnes.uk',
'service_name' => 'Bluesky',
]);
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
'in-reply-to' => ['https://aaronpk.localhost'],
'mp-syndicate-to' => [
'https://mastodon.social/@jonnybarnes',
'https://bsky.app/profile/jonnybarnes.uk',
],
'photo' => [config('filesystems.disks.public.url').'/media/test-photo.jpg'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertStatus(201)
->assertJson(['response' => 'created']);
Queue::assertPushed(SendWebMentions::class);
Queue::assertPushed(SyndicateNoteToMastodon::class);
Queue::assertPushed(SyndicateNoteToBluesky::class);
}
#[Test]
public function micropub_client_api_request_creates_new_note_with_geo_location(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
'location' => ['geo:1.23,4.56'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertStatus(201)
->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', [
'note' => $note,
'location' => '1.23, 4.56',
]);
}
/**
* Test a valid micropub requests using JSON syntax creates a new note with
* existing self-created place.
*/
#[Test]
public function micropub_client_api_request_creates_new_note_with_existing_place_in_location_data(): void
{
$place = new Place;
$place->name = 'Test Place';
$place->latitude = 1.23;
$place->longitude = 4.56;
$place->save();
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
'location' => [$place->uri],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertStatus(201)
->assertJson(['response' => 'created']);
}
/**
* Test a valid micropub requests using JSON syntax creates a new note with
* a new place defined in the location block.
*/
#[Test]
public function micropub_client_api_request_creates_new_note_with_new_place_in_location_data(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
'location' => [
'type' => ['h-card'],
'properties' => [
'name' => ['Awesome Venue'],
'latitude' => ['1.23'],
'longitude' => ['4.56'],
],
],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertStatus(201)
->assertJson(['response' => 'created']);
$this->assertDatabaseHas('places', [
'name' => 'Awesome Venue',
]);
}
/**
* Test a valid micropub requests using JSON syntax creates a new note without
* a new place defined in the location block if there is missing data.
*/
#[Test]
public function micropub_client_api_request_creates_new_note_without_new_place_in_location_data(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
'location' => [[
'type' => ['h-card'],
'properties' => [
'name' => ['Awesome Venue'],
],
]],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertStatus(201)
->assertJson(['response' => 'created']);
$this->assertDatabaseMissing('places', [
'name' => 'Awesome Venue',
]);
}
/**
* Test a micropub requests using JSON syntax without a token returns an
* error. Also check the message.
*/
#[Test]
public function micropub_client_api_request_without_token_returns_error(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
],
]
);
$response
->assertJson([
'response' => 'error',
'error' => 'unauthorized',
])
->assertStatus(401);
}
/**
* Test a micropub requests using JSON syntax without a valid token returns
* an error. Also check the message.
*/
#[Test]
public function micropub_client_api_request_with_token_with_insufficient_permission_returns_error(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'content' => [$note],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response
->assertJson([
'error' => 'insufficient_scope',
'error_description' => 'The token does not have the required scope for this request',
])
->assertStatus(403);
}
#[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void
{
$this->mock(ExceptionHandler::class)->shouldNotReceive('report');
$response = $this->postJson(
'/api/post',
[
'type' => ['h-unsupported'], // a request type I don’t support
'properties' => [
'content' => ['Some content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson([
'error' => 'invalid_request',
'error_description' => "Unsupported Micropub request type 'unsupported'",
])
->assertStatus(400);
}
#[Test]
public function micropub_client_api_request_creates_new_place(): void
{
$faker = Factory::create();
$response = $this->postJson(
'/api/post',
[
'type' => ['h-card'],
'properties' => [
'name' => [$faker->name],
'geo' => ['geo:'.$faker->latitude.','.$faker->longitude],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
}
#[Test]
public function micropub_client_api_request_creates_new_place_with_uncertainty_parameter(): void
{
$faker = Factory::create();
$response = $this->postJson(
'/api/post',
[
'type' => ['h-card'],
'properties' => [
'name' => [$faker->name],
'geo' => ['geo:'.$faker->latitude.','.$faker->longitude.';u=35'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
}
#[Test]
public function micropub_client_api_request_updates_existing_note(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'replace' => [
'content' => ['replaced content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$note->refresh();
$this->assertSame('replaced content', $note->content);
}
#[Test]
public function micropub_client_api_request_updates_existing_note_with_rich_text_content(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'replace' => [
'content' => [['value' => 'plain text', 'html' => 'html version']],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$note->refresh();
$this->assertSame('html version', $note->content);
}
#[Test]
public function micropub_client_api_request_updates_note_syndication_links(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'add' => [
'syndication' => [
'https://www.swarmapp.com/checkin/123',
'https://www.facebook.com/checkin/123',
],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$this->assertDatabaseHas('notes', [
'swarm_url' => 'https://www.swarmapp.com/checkin/123',
'facebook_url' => 'https://www.facebook.com/checkin/123',
]);
}
#[Test]
public function micropub_client_api_request_adds_image_to_note(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'add' => [
'photo' => ['https://example.org/photo.jpg'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$this->assertDatabaseHas('media_endpoint', [
'path' => 'https://example.org/photo.jpg',
]);
}
#[Test]
public function micropub_client_api_request_returns_error_trying_to_update_non_note_model(): void
{
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => config('app.url').'/blog/A',
'add' => [
'syndication' => ['https://www.swarmapp.com/checkin/123'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid_request'])
->assertStatus(400);
}
#[Test]
public function micropub_client_api_request_returns_error_trying_to_update_non_existing_note(): void
{
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => config('app.url').'/notes/ZZZZ',
'add' => [
'syndication' => ['https://www.swarmapp.com/checkin/123'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid_request'])
->assertStatus(404);
}
#[Test]
public function micropub_client_api_request_returns_error_when_trying_to_update_unsupported_property(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'morph' => [ // or any other unsupported update type
'syndication' => ['https://www.swarmapp.com/checkin/123'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid_request'])
->assertStatus(400);
}
#[Test]
public function micropub_client_api_request_with_token_with_insufficient_scope_returns_error(): void
{
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => config('app.url').'/notes/B',
'add' => [
'syndication' => ['https://www.swarmapp.com/checkin/123'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response
->assertStatus(403)
->assertJson(['error' => 'insufficient_scope']);
}
#[Test]
public function micropub_client_api_request_can_replace_note_syndication_targets(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'replace' => [
'syndication' => [
'https://www.swarmapp.com/checkin/the-id',
'https://www.facebook.com/post/the-id',
],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$this->assertDatabaseHas('notes', [
'swarm_url' => 'https://www.swarmapp.com/checkin/the-id',
'facebook_url' => 'https://www.facebook.com/post/the-id',
]);
}
#[Test]
public function micropub_client_web_request_can_encode_token_within_the_form(): void
{
$faker = Factory::create();
$note = $faker->text;
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => $note,
'published' => Carbon::now()->toW3CString(),
'access_token' => (string) $this->getToken(),
]
);
$response->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => $note]);
}
#[Test]
public function micropub_client_api_request_can_combine_replace_and_add_in_single_update(): void
{
$note = Note::factory()->create();
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'replace' => [
'content' => ['replaced content'],
],
'add' => [
'syndication' => ['https://www.swarmapp.com/checkin/123'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$note->refresh();
$this->assertSame('replaced content', $note->content);
$this->assertDatabaseHas('notes', [
'swarm_url' => 'https://www.swarmapp.com/checkin/123',
]);
}
#[Test]
public function micropub_client_api_request_can_delete_entire_property(): void
{
$note = Note::factory()->create([
'swarm_url' => 'https://www.swarmapp.com/checkin/123',
'facebook_url' => 'https://www.facebook.com/post/123',
]);
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'delete' => ['syndication'],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$note->refresh();
$this->assertNull($note->swarm_url);
$this->assertNull($note->facebook_url);
$this->assertNull($note->tweet_id);
}
#[Test]
public function micropub_client_api_request_can_delete_specific_syndication_value(): void
{
$note = Note::factory()->create([
'swarm_url' => 'https://www.swarmapp.com/checkin/123',
'facebook_url' => 'https://www.facebook.com/post/123',
]);
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'delete' => [
'syndication' => ['https://www.swarmapp.com/checkin/123'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$note->refresh();
$this->assertNull($note->swarm_url);
$this->assertSame('https://www.facebook.com/post/123', $note->facebook_url);
}
#[Test]
public function micropub_client_api_request_can_delete_photo(): void
{
$note = Note::factory()->create();
$media = new Media;
$media->path = 'https://example.org/photo.jpg';
$media->type = 'image';
$media->save();
$note->media()->save($media);
$response = $this->postJson(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'delete' => ['photo'],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'updated'])
->assertSuccessful();
$this->assertDatabaseMissing('media_endpoint', [
'path' => 'https://example.org/photo.jpg',
]);
}
#[Test]
public function micropub_client_api_request_creates_articles_when_it_includes_the_name_property(): void
{
$faker = Factory::create();
$name = $faker->text(50);
$content = $faker->paragraphs(5, true);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => [$name],
'content' => [$content],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertDatabaseHas('articles', [
'title' => $name,
'main' => $content,
'published' => true,
]);
$response->assertHeader('Location');
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
}
#[Test]
public function micropub_client_api_request_for_a_like_returns_our_permalink_in_the_location_header(): void
{
Queue::fake();
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'like-of' => ['https://example.org/liked-post'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$like = Like::where('url', 'https://example.org/liked-post')->sole();
$response
->assertStatus(201)
->assertHeader('Location', config('app.url').'/likes/'.$like->id);
}
#[Test]
public function micropub_client_api_request_for_a_bookmark_returns_our_permalink_in_the_location_header(): void
{
Queue::fake();
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'bookmark-of' => ['https://example.org/bookmarked-page'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$bookmark = Bookmark::where('url', 'https://example.org/bookmarked-page')->sole();
$response
->assertStatus(201)
->assertHeader('Location', config('app.url').'/bookmarks/'.$bookmark->id);
}
#[Test]
public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void
{
$faker = Factory::create();
$name = $faker->text(50);
$content = $faker->paragraphs(5, true);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => [$name],
'content' => [$content],
'post-status' => ['draft'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertDatabaseHas('articles', [
'title' => $name,
'main' => $content,
'published' => false,
]);
}
#[Test]
public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void
{
$draft = Article::create([
'title' => 'WireGuard',
'main' => 'Early draft content',
'published' => false,
]);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => ['WireGuard'],
'content' => ['Finished content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['response' => 'created'])
->assertStatus(201);
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
$this->assertDatabaseHas('articles', [
'id' => $draft->id,
'title' => 'WireGuard',
'main' => 'Finished content',
'published' => true,
]);
}
#[Test]
public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void
{
Article::create([
'title' => 'WireGuard',
'main' => 'Published content',
'published' => true,
]);
$response = $this->postJson(
'/api/post',
[
'type' => ['h-entry'],
'properties' => [
'name' => ['WireGuard'],
'content' => ['Some other content'],
],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid_request'])
->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']);
}
}