Skip to content

Commit

Permalink
Merge branch 'feature/sensitive-attributes' into 9.x
Browse files Browse the repository at this point in the history
  • Loading branch information
taylorotwell committed Jul 7, 2021
2 parents 2ab9e6c + c680c74 commit 60e8206
Show file tree
Hide file tree
Showing 7 changed files with 273 additions and 10 deletions.
47 changes: 44 additions & 3 deletions src/ModelObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Laravel\Scout;

use Closure;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Config;

Expand All @@ -14,6 +15,20 @@ class ModelObserver
*/
public $afterCommit;

/**
* Indicates if Scout will keep soft deleted records in the search indexes.
*
* @var bool
*/
protected $usingSoftDeletes;

/**
* Indicates if the model is currently force saving.
*
* @var bool
*/
protected $forceSaving = false;

/**
* The class names that syncing is disabled for.
*
Expand All @@ -29,6 +44,7 @@ class ModelObserver
public function __construct()
{
$this->afterCommit = Config::get('scout.after_commit', false);
$this->usingSoftDeletes = Config::get('scout.soft_delete', false);
}

/**
Expand Down Expand Up @@ -78,6 +94,10 @@ public function saved($model)
return;
}

if (! $this->forceSaving && ! $model->searchIndexShouldBeUpdated()) {
return;
}

if (! $model->shouldBeSearchable()) {
if ($model->wasSearchableBeforeUpdate()) {
$model->unsearchable();
Expand Down Expand Up @@ -105,8 +125,10 @@ public function deleted($model)
return;
}

if ($this->usesSoftDelete($model) && config('scout.soft_delete', false)) {
$this->saved($model);
if ($this->usingSoftDeletes && $this->usesSoftDelete($model)) {
$this->whileForcingUpdate(function () use ($model) {
$this->saved($model);
});
} else {
$model->unsearchable();
}
Expand Down Expand Up @@ -135,7 +157,26 @@ public function forceDeleted($model)
*/
public function restored($model)
{
$this->saved($model);
$this->whileForcingUpdate(function () use ($model) {
$this->saved($model);
});
}

/**
* Execute the given callback while forcing updates.
*
* @param \Closure $callback
* @return mixed
*/
protected function whileForcingUpdate(Closure $callback)
{
$this->forceSaving = true;

$result = $callback();

$this->forceSaving = false;

return $result;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/Searchable.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ public function shouldBeSearchable()
return true;
}

/**
* When updating a model, this method determines if we should update the search index.
*
* @return bool
*/
public function searchIndexShouldBeUpdated()
{
return true;
}

/**
* Perform a search against the model's indexed data.
*
Expand Down
35 changes: 35 additions & 0 deletions tests/Fixtures/SearchableModelWithSensitiveAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Laravel\Scout\Tests\Fixtures;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;

class SearchableModelWithSensitiveAttributes extends Model
{
use Searchable;
use SoftDeletes;

/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['first_name', 'last_name', 'remember_token', 'password'];

/**
* When updating a model, this method determines if we
* should perform a search engine update or not.
*
* @return bool
*/
public function searchShouldUpdate(): bool
{
$sensitiveAttributeKeys = ['first_name', 'last_name'];

return collect($this->getDirty())->keys()
->intersect($sensitiveAttributeKeys)
->isNotEmpty();
}
}
1 change: 1 addition & 0 deletions tests/Unit/AlgoliaEngineTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class AlgoliaEngineTest extends TestCase
protected function setUp(): void
{
Config::shouldReceive('get')->with('scout.after_commit', m::any())->andReturn(false);
Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(false);
}

protected function tearDown(): void
Expand Down
106 changes: 99 additions & 7 deletions tests/Unit/ModelObserverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Laravel\Scout\ModelObserver;
use Laravel\Scout\Tests\Fixtures\SearchableModelWithSensitiveAttributes;
use Laravel\Scout\Tests\Fixtures\SearchableModelWithSoftDeletes;
use Mockery as m;
use PHPUnit\Framework\TestCase;

class ModelObserverTest extends TestCase
{
protected function setUp(): void
{
Config::clearResolvedInstances();
Config::shouldReceive('get')->with('scout.after_commit', m::any())->andReturn(false);
Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(false);
}

protected function tearDown(): void
Expand All @@ -24,11 +28,22 @@ public function test_saved_handler_makes_model_searchable()
{
$observer = new ModelObserver;
$model = m::mock();
$model->shouldReceive('searchShouldUpdate')->andReturn(true);
$model->shouldReceive('shouldBeSearchable')->andReturn(true);
$model->shouldReceive('searchable')->once();
$observer->saved($model);
}

public function test_saved_handler_doesnt_make_model_searchable_when_search_shouldnt_update()
{
$observer = new ModelObserver;
$model = m::mock();
$model->shouldReceive('searchShouldUpdate')->andReturn(false);
$model->shouldReceive('shouldBeSearchable')->andReturn(true);
$model->shouldReceive('searchable')->never();
$observer->saved($model);
}

public function test_saved_handler_doesnt_make_model_searchable_when_disabled()
{
$observer = new ModelObserver;
Expand All @@ -43,6 +58,7 @@ public function test_saved_handler_makes_model_unsearchable_when_disabled_per_mo
{
$observer = new ModelObserver;
$model = m::mock();
$model->shouldReceive('searchShouldUpdate')->andReturn(true);
$model->shouldReceive('shouldBeSearchable')->andReturn(false);
$model->shouldReceive('wasSearchableBeforeUpdate')->andReturn(true);
$model->shouldReceive('searchable')->never();
Expand All @@ -53,7 +69,8 @@ public function test_saved_handler_makes_model_unsearchable_when_disabled_per_mo
public function test_saved_handler_doesnt_make_model_unsearchable_when_disabled_per_model_rule_and_already_unsearchable()
{
$observer = new ModelObserver;
$model = m::mock(Model::class)->makePartial();
$model = m::mock(Model::class);
$model->shouldReceive('searchShouldUpdate')->andReturn(true);
$model->shouldReceive('shouldBeSearchable')->andReturn(false);
$model->shouldReceive('wasSearchableBeforeUpdate')->andReturn(false);
$model->shouldReceive('searchable')->never();
Expand All @@ -75,16 +92,91 @@ public function test_deleted_handler_makes_model_unsearchable()
$observer = new ModelObserver;
$model = m::mock();
$model->shouldReceive('wasSearchableBeforeDelete')->andReturn(true);
$model->shouldReceive('unsearchable');
$model->shouldReceive('unsearchable')->once();
$observer->deleted($model);
}

public function test_restored_handler_makes_model_searchable()
public function test_deleted_handler_on_soft_delete_model_makes_model_unsearchable()
{
$observer = new ModelObserver;
$model = m::mock();
$model->shouldReceive('shouldBeSearchable')->andReturn(true);
$model->shouldReceive('searchable');
$observer->restored($model);
$model = m::mock(SearchableModelWithSoftDeletes::class);
$model->shouldReceive('wasSearchableBeforeDelete')->andReturn(true);
$model->shouldReceive('searchable')->never();
$model->shouldReceive('unsearchable')->once();
$observer->deleted($model);
}

public function test_update_on_sensitive_attributes_triggers_search()
{
$model = m::mock(
new SearchableModelWithSensitiveAttributes([
'first_name' => 'taylor',
'last_name' => 'Otwell',
'remember_token' => 123,
'password' => 'secret',
])
)->makePartial();

// Let's pretend it's in sync with the database.
$model->syncOriginal();

// Update
$model->password = 'extremelySecurePassword';
$model->first_name = 'Taylor';

// Assertions
$model->shouldReceive('searchable')->once();
$model->shouldReceive('unsearchable')->never();

$observer = new ModelObserver;
$observer->saved($model);
}

public function test_update_on_non_sensitive_attributes_doesnt_trigger_search()
{
$model = m::mock(
new SearchableModelWithSensitiveAttributes([
'first_name' => 'taylor',
'last_name' => 'Otwell',
'remember_token' => 123,
'password' => 'secret',
])
)->makePartial();

// Let's pretend it's in sync with the database.
$model->syncOriginal();

// Update
$model->password = 'extremelySecurePassword';
$model->remember_token = 456;

// Assertions
$model->shouldReceive('searchable')->never();
$model->shouldReceive('unsearchable')->never();

$observer = new ModelObserver;
$observer->saved($model);
}

public function test_unsearchable_should_be_called_when_deleting()
{
$model = m::mock(
new SearchableModelWithSensitiveAttributes([
'first_name' => 'taylor',
'last_name' => 'Otwell',
'remember_token' => 123,
'password' => 'secret',
])
)->makePartial();

// Let's pretend it's in sync with the database.
$model->syncOriginal();

// Assertions
$model->shouldReceive('searchable')->never();
$model->shouldReceive('unsearchable')->once();

$observer = new ModelObserver;
$observer->deleted($model);
}
}
83 changes: 83 additions & 0 deletions tests/Unit/ModelObserverWithSoftDeletesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace Laravel\Scout\Tests\Unit;

use Illuminate\Support\Facades\Config;
use Laravel\Scout\ModelObserver;
use Laravel\Scout\Tests\Fixtures\SearchableModelWithSensitiveAttributes;
use Laravel\Scout\Tests\Fixtures\SearchableModelWithSoftDeletes;
use Mockery as m;
use PHPUnit\Framework\TestCase;

class ModelObserverWithSoftDeletesTest extends TestCase
{
protected function setUp(): void
{
Config::clearResolvedInstances();
Config::shouldReceive('get')->with('scout.after_commit', m::any())->andReturn(false);
Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(true);
}

protected function tearDown(): void
{
m::close();
}

public function test_deleted_handler_makes_model_unsearchable_when_it_should_not_be_searchable()
{
$observer = new ModelObserver;
$model = m::mock(SearchableModelWithSoftDeletes::class);
$model->shouldReceive('searchShouldUpdate')->never(); // The saved event is forced
$model->shouldReceive('shouldBeSearchable')->andReturn(false); // Should not be searchable
$model->shouldReceive('wasSearchableBeforeDelete')->andReturn(true);
$model->shouldReceive('wasSearchableBeforeUpdate')->andReturn(true);
$model->shouldReceive('searchable')->never();
$model->shouldReceive('unsearchable')->once();
$observer->deleted($model);
}

public function test_deleted_handler_makes_model_searchable_when_it_should_be_searchable()
{
$observer = new ModelObserver;
$model = m::mock(SearchableModelWithSoftDeletes::class);
$model->shouldReceive('searchShouldUpdate')->never(); // The saved event is forced
$model->shouldReceive('shouldBeSearchable')->andReturn(true); // Should be searchable
$model->shouldReceive('wasSearchableBeforeDelete')->andReturn(true);
$model->shouldReceive('searchable')->once();
$model->shouldReceive('unsearchable')->never();
$observer->deleted($model);
}

public function test_restored_handler_makes_model_searchable()
{
$observer = new ModelObserver;
$model = m::mock(SearchableModelWithSoftDeletes::class);
$model->shouldReceive('searchShouldUpdate')->never();
$model->shouldReceive('shouldBeSearchable')->andReturn(true);
$model->shouldReceive('searchable')->once();
$model->shouldReceive('unsearchable')->never();
$observer->restored($model);
}

public function test_unsearchable_should_be_called_when_deleting()
{
$model = m::mock(
new SearchableModelWithSensitiveAttributes([
'first_name' => 'taylor',
'last_name' => 'Otwell',
'remember_token' => 123,
'password' => 'secret',
])
)->makePartial();

// Let's pretend it's in sync with the database.
$model->syncOriginal();

// Assertions
$model->shouldReceive('searchable')->once();
$model->shouldReceive('unsearchable')->never();

$observer = new ModelObserver;
$observer->deleted($model);
}
}
1 change: 1 addition & 0 deletions tests/Unit/RemoveFromSearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class RemoveFromSearchTest extends TestCase
protected function setUp(): void
{
Config::shouldReceive('get')->with('scout.after_commit', m::any())->andReturn(false);
Config::shouldReceive('get')->with('scout.soft_delete', m::any())->andReturn(false);
}

protected function tearDown(): void
Expand Down

0 comments on commit 60e8206

Please sign in to comment.