Keep an audit log of who did what, and how they came in
Written from model events, spatie's role events and panel sign-ins, only for what a person did, with secrets masked; pruned after a year. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
139
tests/Feature/Api/AuditLogTest.php
Normal file
139
tests/Feature/Api/AuditLogTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Project;
|
||||
use App\Models\Repository;
|
||||
use App\Models\User;
|
||||
use App\Models\Webhook;
|
||||
use App\Support\ContentVersion;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The audit log: what people did, and how they came in.
|
||||
*/
|
||||
class AuditLogTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
protected function actingWithRole(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_api_change_is_logged_with_who_what_and_from_where(): void
|
||||
{
|
||||
$editor = $this->actingWithRole('editor');
|
||||
|
||||
$project = Project::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
|
||||
|
||||
$entry = AuditLog::query()->where('action', 'projects.updated')->sole();
|
||||
|
||||
$this->assertSame($editor->id, $entry->user_id);
|
||||
$this->assertSame('api', $entry->via);
|
||||
$this->assertSame('127.0.0.1', $entry->ip);
|
||||
$this->assertSame((string) $project->id, $entry->subject_id);
|
||||
$this->assertSame(['old' => 'active', 'new' => 'paused'], $entry->changes['status']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function what_the_application_does_by_itself_is_not_logged(): void
|
||||
{
|
||||
Repository::factory()->create()->forceFill(['stars' => 10])->save();
|
||||
|
||||
$this->assertSame(0, AuditLog::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function secrets_are_never_written_down(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Webhook::factory()->create(['secret' => 'never-in-the-log-please']);
|
||||
|
||||
$this->assertStringNotContainsString('never-in-the-log-please', (string) json_encode(AuditLog::query()->get()));
|
||||
$this->assertSame('••••', AuditLog::query()->where('action', 'webhooks.created')->sole()->changes['secret']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_content_version_bump_is_not_an_entry(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
ContentVersion::bump();
|
||||
|
||||
$this->assertSame(0, AuditLog::query()->where('subject_type', 'settings')->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function role_changes_are_logged(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->syncRoles(['admin']);
|
||||
$this->actingAs($admin);
|
||||
|
||||
$someone = User::factory()->create();
|
||||
$someone->syncRoles(['editor']);
|
||||
|
||||
$entry = AuditLog::query()->where('action', 'users.roles_added')->where('subject_id', (string) $someone->id)->sole();
|
||||
|
||||
$this->assertSame(['roles' => ['editor']], $entry->changes);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_machine_token_is_named_in_what_it_did(): void
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$editor->syncRoles(['editor']);
|
||||
|
||||
[$token, $plain] = ApiToken::issue($editor, 'CI', ['technologies.*'], now()->addDay());
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertCreated();
|
||||
|
||||
$entry = AuditLog::query()->where('action', 'technologies.created')->sole();
|
||||
|
||||
$this->assertSame('token', $entry->via);
|
||||
$this->assertSame($token->id, $entry->api_token_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function only_an_admin_reads_the_log(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
$this->getJson('/api/v1/audit')->assertForbidden();
|
||||
|
||||
Project::factory()->create();
|
||||
|
||||
$this->actingWithRole('admin');
|
||||
|
||||
$this->getJson('/api/v1/audit?action=projects.')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.action', 'projects.created')
|
||||
->assertJsonPath('data.0.subject.type', 'projects');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user