Ctrl K
ComunidadGuías…

tests/Feature/AuditEventsTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Team\AuditLog;
use App\Livewire\Team\Index as TeamIndex;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Models\Team;
use App\Models\User;
use App\Traits\Auditable;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Logout;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Psr\Log\LoggerInterface;

class FailingAuditAttributeCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        throw new RuntimeException('sensitive attribute value');
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return $value;
    }
}

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->withoutDefer();

    InstanceSettings::forceCreate(['id' => 0]);
    Once::flush();

    $this->team = Team::factory()->create();
    $this->user = User::factory()->create();
    $this->team->members()->attach($this->user->id, ['role' => 'owner']);
    $this->actingAs($this->user);
    session(['currentTeam' => $this->team]);

    Log::spy();
});

test('audit rows survive a request that stops before the terminate phase', function () {
    $this->withDefer();

    DB::transaction(fn () => Project::factory()->create([
        'team_id' => $this->team->id,
        'name' => 'Website',
    ]));
    auditLog('ui.project.updated', ['team_id' => $this->team->id]);

    // PHP stopped the script (client abort, fatal error), so the deferred callbacks never run.
    app()->forgetScopedInstances();

    expect(AuditEvent::query()->pluck('event')->all())->toBe([
        'ui.project.created',
        'ui.environment.created',
        'ui.project.updated',
    ]);
});

test('audit rows of a rolled back transaction are not written', function () {
    try {
        DB::transaction(function () {
            Project::factory()->create(['team_id' => $this->team->id]);

            throw new RuntimeException('rollback');
        });
    } catch (RuntimeException) {
    }

    expect(AuditEvent::query()->count())->toBe(0);
});

test('multiple audit inserts in one request are all written in order', function () {
    auditLog('ui.application.deployed', [
        'team_id' => $this->team->id,
        'application_uuid' => 'app-123',
    ]);
    auditLog('ui.application.updated', [
        'team_id' => $this->team->id,
        'application_uuid' => 'app-123',
    ]);

    expect(AuditEvent::query()->pluck('event')->all())->toBe([
        'ui.application.deployed',
        'ui.application.updated',
    ]);
});

test('audit persistence failures do not fail the action', function () {
    Schema::rename('audit_events', 'unavailable_audit_events');

    try {
        expect(fn () => auditLog('ui.project.updated', ['team_id' => $this->team->id]))
            ->not->toThrow(Throwable::class);

        Log::shouldHaveReceived('warning')->once()->with(
            'Audit event persistence failed',
            Mockery::on(fn (array $context): bool => $context === [
                'event' => 'ui.project.updated',
                'exception' => QueryException::class,
            ]),
        );
    } finally {
        Schema::rename('unavailable_audit_events', 'audit_events');
    }
});

test('audit logger failures do not fail the action', function (bool $inTransaction) {
    DB::commit();
    Schema::rename('audit_events', 'unavailable_audit_events');
    Log::swap(Mockery::mock(LoggerInterface::class));
    Log::shouldReceive('warning')->once()->withAnyArgs()->andThrow(new RuntimeException('logger unavailable'));

    try {
        $action = fn () => AuditEvent::record('ui.project.updated', ['team_id' => $this->team->id]);

        expect(fn () => $inTransaction ? DB::transaction($action) : $action())
            ->not->toThrow(Throwable::class);
    } finally {
        Schema::rename('unavailable_audit_events', 'audit_events');
    }
})->with([false, true]);

test('audit preparation failures log sanitized diagnostics without failing the action', function () {
    $resourceName = new class
    {
        public function __toString(): string
        {
            throw new RuntimeException('sensitive audit metadata');
        }
    };

    expect(fn () => auditLog('ui.project.updated', [
        'team_id' => $this->team->id,
        'project_name' => $resourceName,
        'secret' => 'must not be logged',
    ]))->not->toThrow(Throwable::class);

    Log::shouldHaveReceived('warning')->once()->with(
        'Audit event preparation failed',
        Mockery::on(fn (array $context): bool => $context === [
            'event' => 'ui.project.updated',
            'exception' => RuntimeException::class,
        ]),
    );
});

test('audit log persists a structured event for the current team', function () {
    auditLog('ui.application.updated', [
        'application_uuid' => 'app-123',
        'application_name' => 'Website',
        'changed' => ['name'],
    ]);

    $event = AuditEvent::query()->sole();

    expect($event->team_id)->toBe($this->team->id)
        ->and($event->actor_id)->toBe($this->user->id)
        ->and($event->actor_email)->toBe($this->user->email)
        ->and($event->source)->toBe('ui')
        ->and($event->action)->toBe('updated')
        ->and($event->resource_type)->toBe('application')
        ->and($event->resource_uuid)->toBe('app-123')
        ->and($event->resource_name)->toBe('Website')
        ->and($event->metadata['changed'])->toBe(['name']);
});

test('audit log records the Cloudflare client ip when the request comes from Cloudflare', function () {
    app()->instance('request', Request::create('/', server: [
        'REMOTE_ADDR' => '162.158.79.140',
        'HTTP_CF_CONNECTING_IP' => '160.79.104.10',
    ]));

    auditLog('api.application.updated', ['application_uuid' => 'app-123']);

    expect(AuditEvent::query()->sole()->ip_address)->toBe('160.79.104.10');
});

test('auditable models record authenticated create update and delete actions', function () {
    $project = Project::factory()->create([
        'team_id' => $this->team->id,
        'name' => 'Website project',
    ]);
    $project->update(['name' => 'Renamed project']);
    $project->delete();

    $events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get();

    expect($events->pluck('event')->all())->toBe([
        'ui.project.created',
        'ui.project.updated',
        'ui.project.deleted',
    ])->and($events[1]->metadata['changed_fields'])->toBe(['name']);
});

test('auditable models store encrypted changes for create update and delete actions', function () {
    $project = Project::factory()->create([
        'team_id' => $this->team->id,
        'name' => 'Website project',
        'description' => 'Original description',
    ]);
    $project->update(['description' => 'Updated description']);
    $project->delete();

    $events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get();

    expect($events[0]->changes)->toMatchArray([
        'name' => ['old' => null, 'new' => 'Website project'],
        'description' => ['old' => null, 'new' => 'Original description'],
    ])->and($events[0]->changes)->not->toHaveKeys(['id', 'uuid', 'team_id', 'created_at', 'updated_at'])
        ->and($events[1]->changes)->toBe([
            'description' => ['old' => 'Original description', 'new' => 'Updated description'],
        ])->and($events[2]->changes)->toMatchArray([
            'name' => ['old' => 'Website project', 'new' => null],
            'description' => ['old' => 'Updated description', 'new' => null],
        ]);

    $storedChanges = DB::table('audit_events')->where('id', $events[1]->id)->value('changes');

    expect($storedChanges)->not->toContain('Original description')
        ->and($storedChanges)->not->toContain('Updated description');
});

test('auditable models exclude hidden and encrypted attributes from changes', function () {
    SharedEnvironmentVariable::query()->create([
        'team_id' => $this->team->id,
        'key' => 'API_TOKEN',
        'value' => 'super-secret',
        'comment' => 'Used by the API',
    ]);

    $event = AuditEvent::query()->where('resource_type', 'shared_environment_variable')->sole();

    expect($event->changes)->toMatchArray([
        'key' => ['old' => null, 'new' => 'API_TOKEN'],
        'comment' => ['old' => null, 'new' => 'Used by the API'],
    ])->and($event->changes)->not->toHaveKey('value')
        ->and(DB::table('audit_events')->where('id', $event->id)->value('changes'))
        ->not->toContain('super-secret');
});

test('auditable models redact nested secrets in object casts', function () {
    Server::flushIdentityMap();
    $server = Server::factory()->create(['team_id' => $this->team->id]);
    $server->proxy = [
        'type' => 'TRAEFIK',
        'credentials' => ['password' => 'old-proxy-password'],
    ];
    $server->save();
    AuditEvent::query()->delete();

    $server->proxy = [
        'type' => 'TRAEFIK',
        'credentials' => ['password' => 'new-proxy-password'],
    ];
    $server->save();

    $changes = AuditEvent::query()->sole()->changes;

    expect($changes['proxy'])->toBe([
        'old' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'],
        'new' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'],
    ]);
});

test('audit changes exclude raw fields that can contain embedded credentials', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    $application = Application::factory()->create(['environment_id' => $environment->id]);
    AuditEvent::query()->delete();

    $application->update([
        'name' => 'Safe name',
        'git_full_url' => 'https://user:embedded-password@example.com/repo.git',
        'build_command' => 'API_KEY=embedded-key npm run build',
    ]);

    expect(AuditEvent::query()->sole()->changes)->toHaveKey('name')
        ->not->toHaveKeys(['git_full_url', 'build_command']);
});

test('audit changes exclude database configuration credentials on create and delete', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    AuditEvent::query()->delete();

    $database = StandaloneRedis::query()->create([
        'name' => 'Redis',
        'environment_id' => $environment->id,
        'destination_type' => Server::class,
        'destination_id' => 0,
        'redis_conf' => 'requirepass embedded-redis-password',
    ]);
    $database->delete();

    $events = AuditEvent::query()->orderBy('id')->get();

    expect($events)->toHaveCount(2);
    foreach ($events as $event) {
        expect($event->changes)->toHaveKey('name')->not->toHaveKey('redis_conf');
    }
});

test('audit metadata redacts nested raw configuration credentials', function () {
    auditLog('ui.server.updated', [
        'team_id' => $this->team->id,
        'configuration' => ['last_saved_proxy_configuration' => 'PASSWORD=must-not-be-recorded'],
    ]);

    expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([
        'last_saved_proxy_configuration' => '[REDACTED]',
    ]);
});

test('audit changes keep model default hidden fields excluded after makeVisible', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    $application = Application::factory()->create(['environment_id' => $environment->id]);
    AuditEvent::query()->delete();
    $application->makeVisible(['domain_dns_statuses']);

    $application->update([
        'name' => 'Updated application',
        'domain_dns_statuses' => ['example.com' => 'private DNS data'],
    ]);

    expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('domain_dns_statuses');
});

test('audit changes redact credentials embedded in nested proxy configuration', function () {
    Server::flushIdentityMap();
    $server = Server::factory()->create(['team_id' => $this->team->id]);
    $server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=old-secret'];
    $server->save();
    AuditEvent::query()->delete();

    $server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=new-secret'];
    $server->save();

    expect(AuditEvent::query()->sole()->changes['proxy'])->toBe([
        'old' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'],
        'new' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'],
    ]);
});

test('audit metadata redacts secrets in serializable objects', function (Closure $configuration) {
    auditLog('ui.server.updated', [
        'team_id' => $this->team->id,
        'configuration' => $configuration(),
    ]);

    expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([
        'enabled' => true,
        'nested' => ['password' => '[REDACTED]'],
    ]);
})->with([
    'plain object' => fn () => (object) [
        'enabled' => true,
        'nested' => (object) ['password' => 'must-not-be-recorded'],
    ],
    'JSON serializable object' => fn () => new class implements JsonSerializable
    {
        public function jsonSerialize(): mixed
        {
            return ['enabled' => true, 'nested' => ['password' => 'must-not-be-recorded']];
        }
    },
]);

test('audit change preparation failures do not fail model writes', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    AuditEvent::query()->delete();
    $project->mergeCasts(['description' => FailingAuditAttributeCast::class]);

    $project->update(['description' => 'Persist this description']);

    expect(Project::query()->findOrFail($project->id)->description)->toBe('Persist this description')
        ->and(AuditEvent::query()->sole()->changes)->toBeNull();

    Log::shouldHaveReceived('warning')->once()->with(
        'Audit change preparation failed',
        Mockery::on(fn (array $context): bool => $context['action'] === 'updated'
            && $context['exception'] === RuntimeException::class
            && array_keys($context) === ['resource_type', 'action', 'exception']),
    );
});

test('auditable models exclude timestamp attributes from changes', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    AuditEvent::query()->delete();

    StandalonePostgresql::query()->create([
        'uuid' => fake()->uuid(),
        'name' => 'Timestamp test database',
        'environment_id' => $environment->id,
        'destination_type' => Server::class,
        'destination_id' => 0,
        'postgres_password' => 'password',
        'last_restart_at' => now(),
    ]);

    expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('last_restart_at');
});

test('deleting a project dispatches deleted events for its environments', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = $project->environments()->sole();
    AuditEvent::query()->delete();

    $project->delete();

    expect(AuditEvent::query()
        ->where('event', 'ui.environment.deleted')
        ->where('resource_uuid', $environment->uuid)
        ->exists())->toBeTrue();
});

test('deleting a team dispatches updated events for transferred system-wide sources', function () {
    Team::factory()->create(['id' => 0]);
    $githubApp = GithubApp::query()->create([
        'name' => 'System GitHub source',
        'team_id' => $this->team->id,
        'is_system_wide' => true,
        'api_url' => 'https://api.github.com',
        'html_url' => 'https://github.com',
    ]);
    $gitlabApp = GitlabApp::query()->create([
        'name' => 'System GitLab source',
        'team_id' => $this->team->id,
        'is_system_wide' => true,
        'api_url' => 'https://gitlab.com/api/v4',
        'html_url' => 'https://gitlab.com',
    ]);
    AuditEvent::query()->delete();

    $this->team->delete();

    expect(AuditEvent::query()
        ->where('event', 'ui.github_app.updated')
        ->where('resource_uuid', $githubApp->uuid)
        ->exists())->toBeTrue()
        ->and(AuditEvent::query()
            ->where('event', 'ui.gitlab_app.updated')
            ->where('resource_uuid', $gitlabApp->uuid)
            ->exists())->toBeTrue();
});

test('auditable model mutations succeed when audit persistence fails', function () {
    Schema::rename('audit_events', 'unavailable_audit_events');

    try {
        $project = Project::factory()->create([
            'team_id' => $this->team->id,
            'name' => 'Persisted project',
        ]);
    } finally {
        Schema::rename('unavailable_audit_events', 'audit_events');
    }

    expect($project->exists)->toBeTrue()
        ->and(Project::query()->whereKey($project->id)->exists())->toBeTrue();
});

test('repeated events for the same resource are each persisted', function () {
    auditLog('api.project.updated', [
        'team_id' => $this->team->id,
        'project_uuid' => 'project-123',
        'changed_fields' => ['name'],
    ]);
    auditLog('api.project.updated', [
        'team_id' => $this->team->id,
        'project_uuid' => 'project-123',
        'changed_fields' => ['description'],
    ]);

    $events = AuditEvent::query()->orderBy('id')->get();

    expect($events)->toHaveCount(2)
        ->and($events[0]->metadata['changed_fields'])->toBe(['name'])
        ->and($events[1]->metadata['changed_fields'])->toBe(['description']);
});

test('automatic and explicit auditing both preserve their events', function () {
    $project = Project::factory()->create([
        'team_id' => $this->team->id,
        'name' => 'Website project',
    ]);

    auditLog('ui.project.created', [
        'team_id' => $this->team->id,
        'project_uuid' => $project->uuid,
        'project_name' => $project->name,
        'audit_description' => 'Project created through the API',
        'request_field' => 'preserved',
    ]);

    $events = AuditEvent::query()->where('event', 'ui.project.created')->orderBy('id')->get();

    expect($events)->toHaveCount(2)
        ->and($events[1]->description)->toBe('Project created through the API')
        ->and($events[1]->metadata['request_field'])->toBe('preserved');
});

test('auditable models ignore unauthenticated mutations', function () {
    auth()->logout();
    AuditEvent::query()->delete();

    Project::factory()->create(['team_id' => $this->team->id]);

    expect(AuditEvent::query()->count())->toBe(0);
});

test('webhook audits resolve the team from the application', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    $application = Application::factory()->create(['environment_id' => $environment->id]);
    auth()->logout();
    AuditEvent::query()->delete();
    session()->forget('currentTeam');

    auditLog('webhook.deployment.queued', [
        'application_uuid' => $application->uuid,
        'application_name' => $application->name,
    ]);

    $this->assertDatabaseHas('audit_events', [
        'team_id' => $this->team->id,
        'event' => 'webhook.deployment.queued',
        'resource_uuid' => $application->uuid,
    ]);
});

test('unauthenticated webhook failures without a team are preserved', function () {
    auth()->logout();
    AuditEvent::query()->delete();
    session()->forget('currentTeam');

    auditLogWebhookFailure('sentinel', 'token_missing');
    auditLogWebhookFailure('stripe', 'invalid_signature');

    $events = AuditEvent::query()->orderBy('id')->get();

    expect($events)->toHaveCount(2)
        ->and($events->pluck('event')->all())->toBe([
            'webhook.sentinel.signature_failed',
            'webhook.stripe.signature_failed',
        ])
        ->and($events->pluck('team_id')->all())->toBe([null, null]);
});

test('early Sentinel and Stripe rejections persist unscoped audit events', function () {
    auth()->logout();
    AuditEvent::query()->delete();
    session()->forget('currentTeam');

    $this->postJson('/api/v1/sentinel/push', [])->assertUnauthorized();

    config(['subscription.stripe_webhook_secret' => 'whsec_test']);
    $this->withHeader('Stripe-Signature', 'invalid')
        ->call('POST', '/webhooks/payments/stripe/events', [], [], [], [], '{}')
        ->assertBadRequest();

    expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([
        'webhook.sentinel.signature_failed',
        'webhook.stripe.signature_failed',
    ])->and(AuditEvent::query()->whereNotNull('team_id')->doesntExist())->toBeTrue();
});

test('unscoped audit events are only visible to the instance team', function () {
    AuditEvent::factory()->create([
        'team_id' => null,
        'description' => 'Unscoped security failure',
    ]);

    Livewire::test(AuditLog::class)
        ->assertDontSee('Unscoped security failure');

    $instanceTeam = Team::factory()->create(['id' => 0]);
    $instanceTeam->members()->attach($this->user->id, ['role' => 'owner']);
    $this->user->unsetRelation('teams');
    session(['currentTeam' => $instanceTeam]);

    Livewire::test(AuditLog::class)
        ->assertSee('Unscoped security failure');
});

test('auditable models identify personal access token mutations as api events', function () {
    $newToken = $this->user->createToken('audit-api');
    $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
    $this->actingAs($this->user->withAccessToken($newToken->accessToken->fresh()));

    Project::factory()->create(['team_id' => $this->team->id]);

    expect(AuditEvent::query()->where('resource_type', 'project')->firstOrFail()->event)
        ->toBe('api.project.created');
});

test('API audit events identify the responsible access token', function () {
    $firstToken = $this->user->createToken('first-token');
    $firstToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
    $secondToken = $this->user->createToken('second-token');
    $secondToken->accessToken->forceFill(['team_id' => $this->team->id])->save();

    foreach ([$firstToken->accessToken->fresh(), $secondToken->accessToken->fresh()] as $token) {
        $this->actingAs($this->user->withAccessToken($token));
        auditLog('api.project.updated', ['team_id' => $this->team->id]);
    }

    $events = AuditEvent::query()->orderBy('id')->get();

    expect($events->pluck('actor_token_id')->all())->toBe([
        $firstToken->accessToken->id,
        $secondToken->accessToken->id,
    ])->and($events->pluck('actor_token_name')->all())->toBe([
        'first-token',
        'second-token',
    ]);

    Livewire::test(AuditLog::class)
        ->assertSee('Token: first-token')
        ->assertSee('Token: second-token');
});

test('API model mutations produce one audit event', function () {
    $this->withoutExceptionHandling();
    $token = $this->user->createToken('audit-api', ['root']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $response = $this->withToken($token->plainTextToken)->postJson('/api/v1/projects', [
        'name' => 'Single API audit event',
    ]);

    $response->assertCreated();

    expect(AuditEvent::query()
        ->where('event', 'api.project.created')
        ->where('resource_uuid', $response->json('uuid'))
        ->count())->toBe(1);
});

test('API application updates produce one audit event', function () {
    $server = Server::factory()->create(['team_id' => $this->team->id]);
    $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    $application = Application::factory()->create([
        'environment_id' => $environment->id,
        'destination_id' => $destination->id,
        'destination_type' => $destination->getMorphClass(),
    ]);
    AuditEvent::query()->delete();

    $token = $this->user->createToken('audit-api', ['root']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $this->withToken($token->plainTextToken)
        ->patchJson("/api/v1/applications/{$application->uuid}", ['description' => 'Updated through API'])
        ->assertOk();

    expect(AuditEvent::query()
        ->where('event', 'api.application.updated')
        ->where('resource_uuid', $application->uuid)
        ->count())->toBe(1);
});

test('deployment queue records rollback and cancellation operations', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    $application = Application::factory()->create(['environment_id' => $environment->id]);
    AuditEvent::query()->delete();

    $deployment = ApplicationDeploymentQueue::query()->create([
        'application_id' => $application->id,
        'deployment_uuid' => 'rollback-deployment',
        'commit' => 'abc123',
        'rollback' => true,
        'status' => 'queued',
    ]);

    $deployment->update(['status' => 'cancelled-by-user']);

    expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([
        'ui.application.rollback',
        'ui.deployment.cancelled',
    ]);
});

test('team resource models opt in to automatic auditing', function (string $model) {
    expect(class_uses_recursive($model))->toContain(Auditable::class);
})->with([
    Application::class,
    Service::class,
    Server::class,
    Project::class,
    Environment::class,
    EnvironmentVariable::class,
    SharedEnvironmentVariable::class,
    PrivateKey::class,
    StandalonePostgresql::class,
    StandaloneMysql::class,
    StandaloneMariadb::class,
    StandaloneMongodb::class,
    StandaloneRedis::class,
    StandaloneKeydb::class,
    StandaloneDragonfly::class,
    StandaloneClickhouse::class,
    StandaloneSqlite::class,
]);

test('withoutAuditLogging suppresses mutations until the outer callback ends', function () {
    $project = Project::factory()->create([
        'team_id' => $this->team->id,
        'name' => 'Original project',
        'description' => 'Original description',
    ]);
    AuditEvent::query()->delete();

    $project->withoutAuditLogging(function () use ($project) {
        $project->update(['name' => 'Outer suppressed']);

        $project->withoutAuditLogging(function () use ($project) {
            $project->update(['description' => 'Inner suppressed']);
        });

        $project->update(['name' => 'Still suppressed']);
    });

    expect(AuditEvent::query()->count())->toBe(0);

    $project->update(['description' => 'Logged after suppression']);

    expect(AuditEvent::query()->pluck('event')->all())->toBe(['ui.project.updated']);
});

test('status-only database updates do not record last online audit changes', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);

    foreach ([StandaloneClickhouse::class, StandaloneRedis::class] as $model) {
        $attributes = [
            'uuid' => fake()->uuid(),
            'name' => 'Status test database',
            'status' => 'exited',
            'environment_id' => $environment->id,
            'destination_type' => Server::class,
            'destination_id' => 0,
        ];
        if ($model === StandaloneClickhouse::class) {
            $attributes['clickhouse_admin_password'] = 'password';
        }

        $database = $model::create($attributes);
        AuditEvent::query()->delete();

        $database->update(['status' => 'running']);

        expect(AuditEvent::query()->count())->toBe(0);

        $database->update(['name' => 'Renamed status test database']);

        $event = AuditEvent::query()->sole();

        expect($event->metadata['changed_fields'])->toBe(['name']);
        AuditEvent::query()->delete();
    }
});

test('audit log redacts sensitive metadata', function () {
    auditLog('api.application.updated', [
        'team_id' => $this->team->id,
        'application_uuid' => 'app-123',
        'token' => 'secret-token',
        'nested' => ['password' => 'secret-password', 'safe' => 'visible'],
    ]);

    $metadata = AuditEvent::query()->sole()->metadata;

    expect($metadata['token'])->toBe('[REDACTED]')
        ->and($metadata['nested']['password'])->toBe('[REDACTED]')
        ->and($metadata['nested']['safe'])->toBe('visible');
});

test('audit severity is persisted and invalid levels fall back to info', function () {
    auditLog('webhook.test.signature_failed', ['team_id' => $this->team->id], 'warning');
    auditLog('ui.project.updated', ['team_id' => $this->team->id], 'invalid');

    expect(AuditEvent::query()->orderBy('id')->pluck('level')->all())->toBe(['warning', 'info']);
});

test('authentication failures and logout are audited without credentials', function () {
    event(new Failed('web', null, ['email' => 'person@example.com', 'password' => 'not-stored']));
    event(new Logout('web', $this->user));

    $failed = AuditEvent::query()->where('event', 'auth.user.login_failed')->sole();
    $logout = AuditEvent::query()->where('event', 'auth.user.logged_out')->sole();

    expect($failed->level)->toBe('warning')
        ->and($failed->metadata)->not->toHaveKey('password')
        ->and($logout->actor_id)->toBe($this->user->id);
});

test('audit log redacts common credential metadata keys', function (string $key) {
    auditLog('api.application.updated', [
        'team_id' => $this->team->id,
        $key => 'sensitive-value',
    ]);

    expect(AuditEvent::query()->sole()->metadata[$key])->toBe('[REDACTED]');
})->with([
    'api_key',
    'access_key',
    'authorization',
    'cookie',
    'client_secret',
]);

test('audit event classification can use explicit resource and action context', function () {
    auditLog('mcp.control', [
        'team_id' => $this->team->id,
        'resource' => 'application',
        'action' => 'restart',
        'resource_uuid' => 'app-123',
    ]);

    $event = AuditEvent::query()->sole();

    expect($event->resource_type)->toBe('application')
        ->and($event->action)->toBe('restart')
        ->and($event->resource_uuid)->toBe('app-123');
});

test('team invitation audit logs redact the invitation email', function () {
    auditLog('ui.team_invitation.created', [
        'team_id' => $this->team->id,
        'invitation_uuid' => 'invitation-123',
        'invitation_email' => 'invitee@example.com',
        'role' => 'member',
        'via' => 'email',
    ]);

    $metadata = AuditEvent::query()->sole()->metadata;

    expect($metadata['invitation_email'])->toBe('[REDACTED]')
        ->and($metadata['invitation_uuid'])->toBe('invitation-123')
        ->and($metadata['role'])->toBe('member')
        ->and($metadata['via'])->toBe('email');
});

test('audit log page only shows events for the current team', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'description' => 'Website created',
    ]);
    AuditEvent::factory()->create([
        'team_id' => Team::factory()->create()->id,
        'description' => 'Private app deleted',
    ]);

    Livewire::test(AuditLog::class)
        ->assertSee('Website created')
        ->assertDontSee('Private app deleted');
});

test('audit log sorts by the chosen column and ignores unknown sort values', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'actor_name' => 'Zoe',
        'description' => 'Older event by Zoe',
        'created_at' => now()->subHour(),
    ]);
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'actor_name' => 'Adam',
        'description' => 'Newer event by Adam',
        'created_at' => now(),
    ]);

    Livewire::test(AuditLog::class)
        ->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
        ->call('setSort', 'time-asc')
        ->assertSet('sort', 'time-asc')
        ->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
        ->call('setSort', 'actor-desc')
        ->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
        ->call('setSort', 'activity-asc')
        ->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
        ->call('setSort', 'resource_uuid-asc')
        ->assertSet('sort', 'activity-asc')
        ->call('setSort', 'time-sideways')
        ->assertSet('sort', 'activity-asc');
});

test('changing the audit log sort returns to the first page', function () {
    AuditEvent::factory()->count(11)->create(['team_id' => $this->team->id]);

    Livewire::test(AuditLog::class)
        ->set('perPage', 10)
        ->call('setPage', 2)
        ->call('setSort', 'time-asc')
        ->assertSet('paginators.page', 1);
});

test('audit log expands encrypted model changes for team admins and owners', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'description' => 'Website updated',
        'changes' => [
            'name' => ['old' => 'Website', 'new' => 'Store'],
        ],
    ]);

    Livewire::test(AuditLog::class)
        ->assertSee('View changes')
        ->assertSee('Name')
        ->assertSee('Website')
        ->assertSee('Store');
});

test('manual audit events do not store model changes', function () {
    auditLog('ui.application.restarted', [
        'team_id' => $this->team->id,
        'application_uuid' => 'app-123',
        'audit_changes' => [
            'name' => ['old' => 'Injected', 'new' => 'Value'],
        ],
    ]);

    $event = AuditEvent::query()->sole();

    expect($event->changes)->toBeNull()
        ->and($event->metadata)->not->toHaveKey('audit_changes');
});

test('audit log is available under team settings', function () {
    $this->get('/team/audit-log')
        ->assertSuccessful()
        ->assertSeeLivewire(AuditLog::class);
});

test('team members cannot view the audit log page', function () {
    $member = User::factory()->create();
    $this->team->members()->attach($member->id, ['role' => 'member']);

    $this->actingAs($member);
    session(['currentTeam' => $this->team]);

    $this->get('/team/audit-log')->assertForbidden();
});

test('demoted team admins cannot make subsequent audit log requests', function () {
    $component = Livewire::test(AuditLog::class);

    $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
    auth()->setUser($this->user->fresh());

    $component->set('search', 'deployment')->assertStatus(403);
});

test('team admins can query only their team audit events through the api', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'event' => 'api.project.updated',
        'source' => 'api',
        'action' => 'updated',
        'description' => 'Visible event',
        'actor_email' => 'owner@example.com',
        'actor_token_name' => 'production token',
        'metadata' => ['changed_fields' => ['name']],
        'changes' => ['name' => ['old' => 'Old project', 'new' => 'New project']],
        'ip_address' => '192.0.2.1',
        'user_agent' => 'Sensitive user agent',
    ]);
    AuditEvent::factory()->create([
        'team_id' => Team::factory()->create()->id,
        'event' => 'api.project.updated',
        'source' => 'api',
        'action' => 'updated',
        'description' => 'Other team event',
    ]);

    $token = $this->user->createToken('audit-read', ['read']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $this->withToken($token->plainTextToken)
        ->getJson('/api/v1/audit-events?source=api&action=updated')
        ->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.description', 'Visible event')
        ->assertJsonMissingPath('data.0.actor_email')
        ->assertJsonMissingPath('data.0.actor_token_id')
        ->assertJsonMissingPath('data.0.actor_token_name')
        ->assertJsonMissingPath('data.0.metadata')
        ->assertJsonMissingPath('data.0.changes')
        ->assertJsonMissingPath('data.0.ip_address')
        ->assertJsonMissingPath('data.0.user_agent');
});

test('team admins with sensitive read access can query full audit event details', function () {
    $token = $this->user->createToken('audit-sensitive-read', ['read', 'read:sensitive']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();
    AuditEvent::query()->delete();

    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'actor_email' => 'owner@example.com',
        'actor_token_name' => 'production token',
        'metadata' => ['changed_fields' => ['name']],
        'changes' => ['name' => ['old' => 'Old project', 'new' => 'New project']],
        'ip_address' => '192.0.2.1',
        'user_agent' => 'Sensitive user agent',
    ]);

    $this->withToken($token->plainTextToken)
        ->getJson('/api/v1/audit-events')
        ->assertOk()
        ->assertJsonPath('data.0.actor_email', 'owner@example.com')
        ->assertJsonPath('data.0.actor_token_name', 'production token')
        ->assertJsonPath('data.0.metadata.changed_fields.0', 'name')
        ->assertJsonPath('data.0.changes.name.old', 'Old project')
        ->assertJsonPath('data.0.changes.name.new', 'New project')
        ->assertJsonPath('data.0.ip_address', '192.0.2.1')
        ->assertJsonPath('data.0.user_agent', 'Sensitive user agent');
});

test('tokens without sensitive read access cannot search private audit fields', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'actor_email' => 'private-audit@example.com',
        'description' => 'Public description',
    ]);

    $token = $this->user->createToken('audit-read', ['read']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $this->withToken($token->plainTextToken)
        ->getJson('/api/v1/audit-events?search=private-audit@example.com')
        ->assertOk()
        ->assertJsonCount(0, 'data');
});

test('team members cannot query audit events through the api', function () {
    $member = User::factory()->create();
    $this->team->members()->attach($member->id, ['role' => 'member']);
    $this->actingAs($member);
    session(['currentTeam' => $this->team]);
    $token = $member->createToken('audit-read', ['read']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $this->withToken($token->plainTextToken)
        ->getJson('/api/v1/audit-events')
        ->assertForbidden();
});

test('audit events api rejects pagination and filter values outside the allowed bounds', function (string $query, string $field) {
    $token = $this->user->createToken('audit-read', ['read']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $this->withToken($token->plainTextToken)
        ->getJson('/api/v1/audit-events?'.$query)
        ->assertUnprocessable()
        ->assertJsonValidationErrors([$field]);
})->with([
    'per_page below minimum' => ['per_page=0', 'per_page'],
    'per_page above maximum' => ['per_page=101', 'per_page'],
    'page below minimum' => ['page=0', 'page'],
    'unsupported source' => ['source=unknown', 'source'],
    'action longer than 255 characters' => ['action='.str_repeat('a', 256), 'action'],
]);

test('audit events api accepts valid pagination and filter values', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'event' => 'api.project.updated',
        'source' => 'api',
        'action' => 'updated',
        'description' => 'Visible event',
    ]);
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'event' => 'ui.project.created',
        'source' => 'ui',
        'action' => 'created',
        'description' => 'Other event',
    ]);

    $token = $this->user->createToken('audit-read', ['read']);
    $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
    auth()->logout();
    auth()->forgetGuards();

    $this->withToken($token->plainTextToken)
        ->getJson('/api/v1/audit-events?per_page=1&source=api&action=updated&search=Visible')
        ->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.description', 'Visible event')
        ->assertJsonPath('per_page', 1);
});

test('audit action filter includes actions recorded for the current team', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'action' => 'backup_schedule_deleted',
    ]);

    Livewire::test(AuditLog::class)
        ->assertViewHas('actionOptions', fn (array $options): bool => in_array([
            'value' => 'backup_schedule_deleted',
            'label' => 'Backup Schedule Deleted',
        ], $options, true));
});

test('critical operational events persist with their source action and actor', function (string $event) {
    auditLog($event, [
        'team_id' => $this->team->id,
        'resource_uuid' => 'resource-123',
        'resource_name' => 'Test resource',
    ]);

    $auditEvent = AuditEvent::query()->sole();

    expect($auditEvent->event)->toBe($event)
        ->and($auditEvent->source)->toBe(str($event)->before('.')->value())
        ->and($auditEvent->action)->toBe(str($event)->afterLast('.')->value())
        ->and($auditEvent->actor_email)->toBe($this->user->email);
})->with([
    'ui.application.stopped',
    'ui.application.preview_stopped',
    'ui.application.destination_stopped',
    'ui.application.rollback',
    'ui.deployment.cancelled',
    'ui.service.started',
    'ui.service.stopped',
    'ui.service.restarted',
    'ui.database.started',
    'ui.database.stopped',
    'ui.database.restarted',
    'ui.proxy.stopped',
    'ui.proxy.restarted',
    'ui.database.backup_started',
    'ui.database.backup_schedule_deleted',
    'ui.database.import_started',
    'ui.database.restore_started',
    'ui.scheduled_task.executed',
    'ui.api_token.created',
    'ui.api_token.updated',
    'ui.api_token.paused',
    'ui.api_token.resumed',
    'ui.api_token.revoked',
    'ui.team_member.role_updated',
    'ui.team_member.removed',
    'ui.team_invitation.created',
    'ui.team_invitation.revoked',
    'ui.server.docker_cleanup_started',
    'ui.server.imported',
    'ui.project.clone_started',
    'ui.resource.clone_started',
    'api.database.started',
    'api.database.stopped',
    'api.database.restarted',
    'api.database.import_started',
]);

test('audit log displays source abbreviations in uppercase', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'source' => 'cli',
    ]);

    Livewire::test(AuditLog::class)
        ->assertSee('CLI');
});

test('audit log page filters events by search and action', function () {
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'action' => 'created',
        'description' => 'Website created',
        'resource_name' => 'Website',
    ]);
    AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'event' => 'api.server.deleted',
        'action' => 'deleted',
        'description' => 'Build server deleted',
        'resource_name' => 'Build server',
    ]);

    Livewire::test(AuditLog::class)
        ->set('search', 'Website')
        ->assertSee('Website created')
        ->assertDontSee('Build server deleted')
        ->set('search', '')
        ->set('action', 'deleted')
        ->assertDontSee('Website created')
        ->assertSee('Build server deleted');
});

test('updating team settings records an audit event', function () {
    Livewire::test(TeamIndex::class)
        ->set('name', 'Renamed team')
        ->call('submit')
        ->assertHasNoErrors();

    $event = AuditEvent::query()->where('action', 'updated')->sole();

    expect($event->event)->toBe('ui.team.updated')
        ->and($event->team_id)->toBe($this->team->id)
        ->and($event->resource_name)->toBe('Renamed team');
});

test('updating an environment variable records an event without its value', function () {
    $variable = SharedEnvironmentVariable::create([
        'team_id' => $this->team->id,
        'type' => 'team',
        'key' => 'API_SECRET',
        'value' => 'old-secret',
    ]);

    Livewire::test(Show::class, [
        'env' => $variable,
        'type' => 'team',
    ])
        ->call('loadValues')
        ->set('value', 'new-secret')
        ->call('submit')
        ->assertHasNoErrors();

    $event = AuditEvent::query()
        ->where('resource_type', 'shared_environment_variable')
        ->where('action', 'updated')
        ->sole();

    expect($event->event)->toBe('ui.shared_environment_variable.updated')
        ->and($event->resource_name)->toBe('API_SECRET')
        ->and(json_encode($event->metadata))->not->toContain('new-secret');
});

test('creating an application environment variable records an audit event', function () {
    $project = Project::factory()->create(['team_id' => $this->team->id]);
    $environment = Environment::factory()->create(['project_id' => $project->id]);
    $application = Application::factory()->create(['environment_id' => $environment->id]);

    $application->environment_variables()->create([
        'key' => 'API_SECRET',
        'value' => 'secret-value',
    ]);

    $event = AuditEvent::query()
        ->where('resource_type', 'environment_variable')
        ->where('action', 'created')
        ->where('resource_name', 'API_SECRET')
        ->firstOrFail();

    expect($event->team_id)->toBe($this->team->id)
        ->and($event->resource_name)->toBe('API_SECRET')
        ->and(json_encode($event->metadata))->not->toContain('secret-value');
});

test('database cleanup removes audit events older than 90 days', function () {
    $old = AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'created_at' => now()->subDays(91),
    ]);
    $recent = AuditEvent::factory()->create([
        'team_id' => $this->team->id,
        'created_at' => now()->subDays(89),
    ]);

    AuditEvent::pruneExpired();

    expect($old->fresh())->toBeNull()
        ->and($recent->fresh())->not->toBeNull();
});