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>
235 lines
8.3 KiB
PHP
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')
|
|
);
|
|
});
|
|
});
|