Files
lotteryLaravel/tests/Feature/AdminReportJobScopeTest.php

235 lines
8.3 KiB
PHP

<?php
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Laravel\Sanctum\Sanctum;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
/**
* @param list<string> $permissionCodes
*/
function makeReportScopeAdmin(string $username, int $siteId, array $permissionCodes): AdminUser
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => $username,
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'report_scope_'.$username,
'code' => 'report_scope_'.$username,
'name' => 'Report Scope '.$username,
'scope_type' => AdminRole::SCOPE_SYSTEM,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
]);
$actionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', $permissionCodes)
->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => (int) $role->id,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => (int) $admin->id,
'site_id' => $siteId,
'role_id' => (int) $role->id,
'granted_at' => now(),
]);
return $admin;
}
function makeReportScopeJob(AdminUser $owner, string $jobNo, string $reportType = 'daily_profit_summary'): ReportJob
{
return ReportJob::query()->create([
'job_no' => $jobNo,
'admin_user_id' => (int) $owner->id,
'report_type' => $reportType,
'export_format' => 'csv',
'filter_json' => [
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
],
'status' => 'completed',
'output_path' => 'reports/'.$jobNo.'.csv',
'finished_at' => now(),
]);
}
function makeReportScopeSuperAdmin(): AdminUser
{
$admin = AdminUser::query()->create([
'username' => 'report_scope_super',
'name' => 'Report Scope Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin;
}
test('report job list show and download are owner only with an explicit super admin exception', function (): void {
$siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteB = AdminSite::query()->create([
'code' => 'report-scope-b',
'name' => 'Report Scope B',
'currency_code' => 'NPR',
'status' => 1,
'is_default' => false,
]);
$permissions = ['service.report.view', 'service.report.export'];
$owner = makeReportScopeAdmin('report_owner', $siteAId, $permissions);
$sameSitePeer = makeReportScopeAdmin('report_same_site_peer', $siteAId, $permissions);
$crossSitePeer = makeReportScopeAdmin('report_cross_site_peer', (int) $siteB->id, $permissions);
$ownedJob = makeReportScopeJob($owner, 'RPT-SCOPE-OWN');
$sameSiteJob = makeReportScopeJob($sameSitePeer, 'RPT-SCOPE-SAME');
$crossSiteJob = makeReportScopeJob($crossSitePeer, 'RPT-SCOPE-CROSS');
$deletedCreator = makeReportScopeAdmin('report_deleted_creator', $siteAId, $permissions);
$orphanedJob = makeReportScopeJob($deletedCreator, 'RPT-SCOPE-ORPHAN');
$deletedCreator->delete();
$orphanedJob->refresh();
expect($orphanedJob->admin_user_id)->toBeNull();
Sanctum::actingAs($owner, ['*']);
$visibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($visibleIds)->toBe([(int) $ownedJob->id]);
$this->getJson('/api/v1/admin/report-jobs/'.$ownedJob->id)->assertOk();
$this->get('/api/v1/admin/report-jobs/'.$ownedJob->id.'/download')->assertOk();
foreach ([$sameSiteJob, $crossSiteJob, $orphanedJob] as $deniedJob) {
$this->getJson('/api/v1/admin/report-jobs/'.$deniedJob->id)->assertForbidden();
$this->get('/api/v1/admin/report-jobs/'.$deniedJob->id.'/download')->assertForbidden();
}
$super = makeReportScopeSuperAdmin();
Sanctum::actingAs($super, ['*']);
$superVisibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($superVisibleIds)->toContain(
(int) $ownedJob->id,
(int) $sameSiteJob->id,
(int) $crossSiteJob->id,
(int) $orphanedJob->id,
);
$this->getJson('/api/v1/admin/report-jobs/'.$orphanedJob->id)->assertOk();
$this->get('/api/v1/admin/report-jobs/'.$orphanedJob->id.'/download')->assertOk();
});
test('sensitive report types require their own capabilities and risk export is super admin only', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$base = makeReportScopeAdmin('report_base_exporter', $siteId, [
'service.report.view',
'service.report.export',
]);
$auditor = makeReportScopeAdmin('report_auditor', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
$riskViewer = makeReportScopeAdmin('report_risk_viewer', $siteId, [
'service.report.view',
'service.report.export',
'risk.monitor.view',
]);
Sanctum::actingAs($base, ['*']);
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
])->assertForbidden();
$legacyAuditJob = makeReportScopeJob($base, 'RPT-SENSITIVE-AUDIT', 'audit_operation_report');
$this->get('/api/v1/admin/report-jobs/'.$legacyAuditJob->id.'/download')->assertForbidden();
Sanctum::actingAs($auditor, ['*']);
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
])->assertOk();
Sanctum::actingAs($riskViewer, ['*']);
$legacyRiskJob = makeReportScopeJob($riskViewer, 'RPT-SENSITIVE-RISK', 'hot_number_risk_report');
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => $reportType,
])->assertForbidden();
}
$this->get('/api/v1/admin/report-jobs/'.$legacyRiskJob->id.'/download')->assertForbidden();
$super = makeReportScopeSuperAdmin();
Sanctum::actingAs($super, ['*']);
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
$created = $this->postJson('/api/v1/admin/report-jobs', [
'report_type' => $reportType,
])->assertOk();
$this->get('/api/v1/admin/report-jobs/'.(int) $created->json('data.id').'/download')->assertOk();
}
});
test('non super audit export is limited to the current actor', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$auditor = makeReportScopeAdmin('report_audit_owner', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
$other = makeReportScopeAdmin('report_audit_other', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
AuditLogger::recordForAdmin($auditor, null, 'audit_scope_own', 'own_action', null, null, null, null);
AuditLogger::recordForAdmin($other, null, 'audit_scope_other', 'other_action', null, null, null, null);
Sanctum::actingAs($auditor, ['*']);
$create = $this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
'export_format' => 'csv',
'parameters' => [
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
],
])->assertOk();
$content = $this->get('/api/v1/admin/report-jobs/'.(int) $create->json('data.id').'/download')
->assertOk()
->streamedContent();
expect($content)->toContain('audit_scope_own')
->not->toContain('audit_scope_other');
});