artisan('lottery:admin-auth-sync')->assertExitCode(0); }); /** * @param list $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'); });