Files
website/website/tests/Feature/AuditLogExportTest.php
Claude Dev 45d25d61ba Idempotent provisioning, service soft-delete, Plans page redesign, doc updates
Part A: Fix duplicate Service creation on provisioning retry
- All 4 provisioning services use Service::firstOrCreate() keyed on
  subscription_id+service_type to prevent duplicates on queue retries
- HandleSubscriptionCreated sends notification before provisioning,
  no longer re-throws on failure
- RetryProvisioningCommand simplified to reuse existing Service records

Part B: Plans/Pricing page complete redesign
- Service type tabs (VPS, Dedicated, Web Hosting, MySQL)
- Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual)
- Feature icons per service type, Popular/Best Value badges
- Stock indicators, effective monthly price calculations

Part C: Admin service soft-delete/archive
- Service model uses SoftDeletes trait
- Admin can archive and restore services
- Show archived toggle on services list
- Migration adds deleted_at column

Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md
- Phase 3 marked complete, test counts updated (252 passing)
- SupportPal references replaced with standalone ticket system
- Frontend design skill background rule added
- Closed GitHub issues #3, #6, #7, #8, #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 06:30:57 -05:00

235 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
});
describe('Audit Log Export', function (): void {
it('exports audit logs as CSV', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Test Customer']);
AuditLog::factory()->count(3)->create([
'user_id' => $customer->id,
'action' => 'login',
'changes' => ['before' => ['status' => 'inactive'], 'after' => ['status' => 'active']],
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv');
$response->assertOk();
$response->assertHeader('content-type', 'text/csv; charset=UTF-8');
$response->assertDownload();
});
it('includes all required fields in CSV export', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Test Customer', 'email' => 'test@example.com']);
AuditLog::factory()->create([
'user_id' => $customer->id,
'action' => 'update_profile',
'resource_type' => 'user',
'resource_id' => $customer->id,
'ip_address' => '192.168.1.1',
'user_agent' => 'Mozilla/5.0',
'changes' => ['before' => ['name' => 'Old Name'], 'after' => ['name' => 'New Name']],
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv');
$response->assertOk();
$csv = $response->streamedContent();
$lines = explode("\n", $csv);
// Check header row
expect($lines[0])->toContain('ID')
->and($lines[0])->toContain('Date')
->and($lines[0])->toContain('User')
->and($lines[0])->toContain('User Email')
->and($lines[0])->toContain('Action')
->and($lines[0])->toContain('Resource Type')
->and($lines[0])->toContain('Resource ID')
->and($lines[0])->toContain('IP Address')
->and($lines[0])->toContain('User Agent')
->and($lines[0])->toContain('Changes Summary');
// Check data row
expect($lines[1])->toContain('Test Customer')
->and($lines[1])->toContain('test@example.com')
->and($lines[1])->toContain('update_profile')
->and($lines[1])->toContain('user')
->and($lines[1])->toContain('192.168.1.1')
->and($lines[1])->toContain('Mozilla/5.0');
});
it('exports audit logs as JSON', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Test Customer']);
AuditLog::factory()->count(3)->create([
'user_id' => $customer->id,
'action' => 'update_plan',
'resource_type' => 'plan',
'resource_id' => 1,
'changes' => ['before' => ['name' => 'Basic'], 'after' => ['name' => 'Pro']],
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=json');
$response->assertOk();
$response->assertHeader('content-type', 'application/json');
$response->assertDownload();
});
it('applies search filter to CSV export', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Alice']);
AuditLog::factory()->create([
'user_id' => $customer->id,
'action' => 'login',
]);
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'create_plan',
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv&action=login');
$response->assertOk();
$response->assertDownload();
});
it('applies date range filter to export', function (): void {
$admin = User::factory()->admin()->create();
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'login',
'created_at' => now()->subDays(5),
]);
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'update_settings',
'created_at' => now(),
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv&date_from='.now()->subDays(2)->format('Y-m-d').'&date_to='.now()->format('Y-m-d'));
$response->assertOk();
$response->assertDownload();
});
it('requires format parameter for export', function (): void {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export');
$response->assertInvalid(['format']);
});
it('rejects invalid format parameter', function (): void {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=xml');
$response->assertInvalid(['format']);
});
it('denies customer access to audit log export', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/audit-logs/export?format=csv')
->assertForbidden();
});
it('redirects guest when exporting audit logs', function (): void {
$this->get($this->adminUrl.'/audit-logs/export?format=csv')
->assertRedirect();
});
it('applies all filters simultaneously to CSV export', function (): void {
$admin = User::factory()->admin()->create();
$alice = User::factory()->customer()->create(['name' => 'Alice', 'email' => 'alice@example.com']);
$bob = User::factory()->customer()->create(['name' => 'Bob', 'email' => 'bob@example.com']);
// Create logs with different actions and dates
AuditLog::factory()->create([
'user_id' => $alice->id,
'action' => 'login',
'created_at' => now()->subDays(3),
]);
AuditLog::factory()->create([
'user_id' => $alice->id,
'action' => 'update_profile',
'created_at' => now()->subDay(),
]);
AuditLog::factory()->create([
'user_id' => $bob->id,
'action' => 'login',
'created_at' => now()->subDay(),
]);
// Export with multiple filters (search=alice, action=login, recent dates)
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv&search=alice&action=login&date_from='.now()->subDays(4)->format('Y-m-d').'&date_to='.now()->format('Y-m-d'));
$response->assertOk();
$csv = $response->streamedContent();
$lines = array_filter(explode("\n", $csv));
// Should only include 1 data row (Alice's login) + 1 header row
expect(count($lines))->toBe(2);
expect($lines[1])->toContain('Alice');
expect($lines[1])->toContain('login');
});
});
describe('Audit Log Index - Changes Detail', function (): void {
it('displays audit logs with changes data', function (): void {
$admin = User::factory()->admin()->create();
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'update_plan',
'resource_type' => 'plan',
'resource_id' => 1,
'changes' => [
'before' => ['name' => 'Basic', 'price' => '9.99'],
'after' => ['name' => 'Pro', 'price' => '19.99'],
],
]);
$this->actingAs($admin)
->get($this->adminUrl.'/audit-logs')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/AuditLogs/Index')
->has('auditLogs.data', 1)
->where('auditLogs.data.0.changes.before.name', 'Basic')
->where('auditLogs.data.0.changes.after.name', 'Pro')
);
});
});