diff --git a/app/Http/Resources/WpPostResource.php b/app/Http/Resources/WpPostResource.php index 88891d4..6b6f80f 100644 --- a/app/Http/Resources/WpPostResource.php +++ b/app/Http/Resources/WpPostResource.php @@ -37,7 +37,7 @@ public function toArray(Request $request): array $postData = [ 'id' => $this->ID, - 'author' => $this->author, + 'author' => $this->getAuthor(), 'title' => $this->post_title, 'excerpt' => $this->post_excerpt, 'slug' => $this->post_name, @@ -64,6 +64,28 @@ public function toArray(Request $request): array return $postData; } + /** + * Only the author fields the frontend actually consumes. + * + * The author relation also selects user_email, which WpAuthorService::getAvatar() + * needs to build the Gravatar hash. Serializing the whole model would ship that + * address to the browser on every post of every listing. + * + * @return array|null + */ + private function getAuthor(): ?array + { + if (! $this->author) { + return null; + } + + return [ + 'ID' => $this->author->ID, + 'display_name' => $this->author->display_name, + 'user_nicename' => $this->author->user_nicename, + ]; + } + private function getCategories() { return $this->terms diff --git a/app/Models/WpUser.php b/app/Models/WpUser.php index 4136f71..0ab11ce 100644 --- a/app/Models/WpUser.php +++ b/app/Models/WpUser.php @@ -14,6 +14,21 @@ class WpUser extends Model { protected $table = 'wp_users'; + /** + * Never serialize credentials or contact data. + * + * Attribute access still works (getAvatar() reads user_email), this only + * affects toArray()/toJson() — so an accidental `'author' => $model` cannot + * leak these fields into a response again. + * + * @var list + */ + protected $hidden = [ + 'user_pass', + 'user_email', + 'user_activation_key', + ]; + public function metadata(): HasMany { return $this->hasMany(WpUserMeta::class, 'user_id', 'ID'); diff --git a/phpunit.xml b/phpunit.xml index c09b5bc..61c031c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,7 +22,8 @@ - + + diff --git a/tests/Feature/WpPostResourceTest.php b/tests/Feature/WpPostResourceTest.php new file mode 100644 index 0000000..404114c --- /dev/null +++ b/tests/Feature/WpPostResourceTest.php @@ -0,0 +1,126 @@ +id('ID'); + $table->string('display_name'); + $table->string('user_nicename'); + $table->string('user_email'); + $table->string('user_pass'); + }); + + Schema::create('wp_posts', function (Blueprint $table) { + $table->id('ID'); + $table->unsignedBigInteger('post_author')->default(0); + $table->string('post_title'); + $table->text('post_excerpt'); + $table->text('post_content'); + $table->string('post_name'); + $table->string('post_type')->default('post'); + $table->string('post_status')->default('publish'); + $table->dateTime('post_date'); + $table->string('guid')->default(''); + }); + + Schema::create('wp_postmeta', function (Blueprint $table) { + $table->id('meta_id'); + $table->unsignedBigInteger('post_id'); + $table->string('meta_key'); + $table->text('meta_value'); + }); +}); + +/** + * Builds a post with its relations pre-set, so the assertions below exercise the + * resource and nothing else. + */ +function makePost(?WpUser $author): WpPost +{ + // forceFill: the Wp* models declare no $fillable + $post = (new WpPost)->forceFill([ + 'ID' => 1, + 'post_title' => 'Um post', + 'post_excerpt' => 'resumo', + 'post_content' => 'conteudo', + 'post_name' => 'um-post', + 'post_date' => '2026-07-01 10:00:00', + ]); + + $post->exists = true; + + $post->setRelation('author', $author); + $post->setRelation('terms', new Collection); + $post->setRelation('metadata', new Collection); + + return $post; +} + +function authorPayload(?WpUser $author): array +{ + $resource = WpPostResource::make(makePost($author)); + + return $resource->toArray(Request::create('/')); +} + +it('does not expose the author email in the payload', function () { + $author = (new WpUser)->forceFill([ + 'ID' => 2, + 'display_name' => 'Mayron Câmara', + 'user_nicename' => 'mayron', + 'user_email' => 'autor@example.com', + ]); + + $payload = authorPayload($author); + + expect($payload['author'])->toBe([ + 'ID' => 2, + 'display_name' => 'Mayron Câmara', + 'user_nicename' => 'mayron', + ]); + + expect(json_encode($payload))->not->toContain('autor@example.com'); + expect(json_encode($payload))->not->toContain('user_email'); +}); + +it('keeps the fields the frontend consumes', function () { + $author = (new WpUser)->forceFill([ + 'ID' => 2, + 'display_name' => 'Mayron Câmara', + 'user_nicename' => 'mayron', + 'user_email' => 'autor@example.com', + ]); + + // types/index.d.ts declares PostAuthor as { ID, display_name, user_nicename } + expect(authorPayload($author)['author']) + ->toHaveKeys(['ID', 'display_name', 'user_nicename']); +}); + +it('returns null when the post has no author, as before', function () { + expect(authorPayload(null)['author'])->toBeNull(); +}); + +it('hides credentials when a WpUser is serialized directly', function () { + $user = (new WpUser)->forceFill([ + 'display_name' => 'Mayron Câmara', + 'user_nicename' => 'mayron', + 'user_email' => 'autor@example.com', + 'user_pass' => '$P$Bxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + ]); + + expect($user->toArray()) + ->not->toHaveKey('user_email') + ->not->toHaveKey('user_pass'); + + // attribute access still works — getAvatar() depends on it + expect($user->user_email)->toBe('autor@example.com'); +});