Files
opensourcepos/tests/Controllers/ReportsControllerTest.php
T
objecttothis 33efb401bf Merge remote-tracking branch 'OpensourcePOS/master' into feat-export-toggle-all
# Conflicts:
#	composer.json
#	composer.lock
#	tests/Controllers/CashupControllerTest.php
#	tests/Controllers/EmployeesControllerTest.php
#	tests/Controllers/ItemKitsControllerTest.php
#	tests/Controllers/SalesControllerTest.php
#	tests/Controllers/TaxesControllerTest.php
#	tests/Helpers/export_helperTest.php
#	tests/Helpers/tabular_helperTest.php
#	tests/Helpers/tax_helperTest.php
2026-10-23 11:20:22 +04:00

351 lines
14 KiB
PHP

<?php
namespace Tests\Controllers;
use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use App\Models\Employee;
use Config\OSPOS;
use Tests\Support\DatabaseBootstrapTrait;
/**
* Regression tests for the reports permission bypass
*
* Reports::__construct() previously derived the report method name from
* $request->getUri()->getSegment(2), which CodeIgniter decodes once, while
* the router decodes the same path a second time before dispatch. Encoding
* the report name's underscore as %255F meant the constructor saw no
* underscore, skipped the has_grant() check entirely, and the router still
* dispatched to the real (double-decoded) method — letting any authenticated
* employee read reports they had no grant for.
*/
class ReportsControllerTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use DatabaseBootstrapTrait;
use FeatureTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = false;
protected $namespace = null;
/**
* Set up test environment
*/
protected function setUp(): void
{
$this->resetDatabaseOnce();
parent::setUp();
config(OSPOS::class)->update_settings();
}
/**
* Create a non-admin employee for testing
*
* @param array $overrides
* @return int
*/
protected function createNonAdminEmployee(array $overrides = []): int
{
$uniqueSuffix = uniqid();
$personData = [
'first_name' => $overrides['first_name'] ?? 'NonAdmin',
'last_name' => $overrides['last_name'] ?? 'User',
'email' => $overrides['email'] ?? "nonadmin{$uniqueSuffix}@test.com",
'phone_number' => $overrides['phone_number'] ?? '555-1234'
];
$employeeData = [
'username' => $overrides['username'] ?? "nonadmin{$uniqueSuffix}",
'password' => password_hash($overrides['password'] ?? 'password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = $overrides['grants'] ?? [
['permission_id' => 'customers', 'menu_group' => 'home'],
['permission_id' => 'sales', 'menu_group' => 'home']
];
$employeeModel = model(Employee::class);
$saved = $employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
$this->assertTrue($saved, 'Failed to save non-admin employee fixture.');
$this->assertArrayHasKey('person_id', $personData, 'Saved employee fixture missing person_id.');
return (int) $personData['person_id'];
}
/**
* Log in as the given employee
*
* @param int $personId
* @return void
*/
protected function loginAs(int $personId): void
{
$this->withSession([
'person_id' => $personId,
'menu_group' => 'home',
]);
}
// Headers (Content-Disposition etc.) are only built in DownloadResponse::send(),
// which the test harness never calls; force it so header assertions work.
protected function buildDownloadHeaders($response): void
{
if ($response instanceof DownloadResponse) {
$response->buildHeaders();
}
}
/**
* A non-admin employee with no reports_customers grant must be denied
* access to the summary_customers report.
*
* @return void
*/
public function testNonAdminWithoutReportGrantIsDeniedSummaryCustomers(): void
{
$nonAdminId = $this->createNonAdminEmployee([
'grants' => [
['permission_id' => 'reports_sales', 'menu_group' => 'home']
]
]);
$this->loginAs($nonAdminId);
$response = $this->get('/reports/summary_customers');
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
/**
* Regression test for the double-URL-encoding bypass itself: replacing
* the underscore with %255F must not change the outcome versus the
* plain request in testNonAdminWithoutReportGrantIsDeniedSummaryCustomers().
*
* @return void
*/
public function testDoubleEncodedUnderscoreCannotBypassSummaryCustomersGrantCheck(): void
{
$nonAdminId = $this->createNonAdminEmployee([
'grants' => [
['permission_id' => 'reports_sales', 'menu_group' => 'home']
]
]);
$this->loginAs($nonAdminId);
$response = $this->get('/reports/summary%255Fcustomers');
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
/**
* Same bypass attempt against a different report prefix, to confirm the
* fix isn't narrowly specific to the summary_ prefix's regex path.
*
* @return void
*/
public function testDoubleEncodedUnderscoreCannotBypassDetailedSalesGrantCheck(): void
{
$nonAdminId = $this->createNonAdminEmployee([
'grants' => [
['permission_id' => 'reports_customers', 'menu_group' => 'home']
]
]);
$this->loginAs($nonAdminId);
$response = $this->get('/reports/detailed%255Fsales');
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
/**
* An employee with the reports_customers grant must be able to access
* the summary_customers report.
*
* @return void
*/
public function testEmployeeWithReportGrantCanAccessSummaryCustomers(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'reportviewer',
'email' => 'reportviewer@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_customers', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$response = $this->get('/reports/summary_customers');
$response->assertStatus(200);
}
/**
* An employee with the base reports grant plus a submodule grant must be
* able to access the base /reports listing route (no submodule id derivable).
*
* @return void
*/
public function testEmployeeWithReportsGrantCanAccessBaseReportsIndex(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'reportsindexviewer',
'email' => 'reportsindexviewer@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_customers', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$response = $this->get('/reports');
$response->assertStatus(200);
}
public function testSummarySalesExportAll_StreamsCsvDownloadForGrantedEmployee(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'summarysalesexporter',
'email' => 'summarysalesexporter@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_sales', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$response = $this->get('/reports/summary_sales/exportAll/csv/2024-01-01/2024-12-31/sales/all');
$response->assertStatus(200);
$this->buildDownloadHeaders($response->response());
$this->assertStringContainsString('attachment', $response->response()->getHeaderLine('Content-Disposition'));
}
public function testSummarySalesExportAll_DeniesEmployeeWithoutReportsSalesGrant(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'summarysalesdenied',
'email' => 'summarysalesdenied@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_customers', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$response = $this->get('/reports/summary_sales/exportAll/csv/2024-01-01/2024-12-31/sales/all');
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testSpecificCustomersExportAll_StreamsCsvDownloadForGrantedEmployee(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'specificcustomersexporter',
'email' => 'specificcustomersexporter@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_customers', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$response = $this->get('/reports/specific_customers/exportAll/csv/2024-01-01/2024-12-31/1/sales/all');
$response->assertStatus(200);
$this->buildDownloadHeaders($response->response());
$this->assertStringContainsString('attachment', $response->response()->getHeaderLine('Content-Disposition'));
}
public function testInventorySummaryExportAll_StreamsCsvDownloadForGrantedEmployee(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'inventorysummaryexporter',
'email' => 'inventorysummaryexporter@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_inventory', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$response = $this->get('/reports/inventory_summary/exportAll/csv/all/all');
$response->assertStatus(200);
$this->buildDownloadHeaders($response->response());
$this->assertStringContainsString('attachment', $response->response()->getHeaderLine('Content-Disposition'));
}
public function testAllReportExportAllMethods_ReturnDownloadableResponseForGrantedEmployee(): void
{
$employeeId = $this->createNonAdminEmployee([
'username' => 'allexportsviewer',
'email' => 'allexportsviewer@test.com',
'grants' => [
['permission_id' => 'reports', 'menu_group' => 'home'],
['permission_id' => 'reports_sales', 'menu_group' => 'home'],
['permission_id' => 'reports_customers', 'menu_group' => 'home'],
['permission_id' => 'reports_categories', 'menu_group' => 'home'],
['permission_id' => 'reports_suppliers', 'menu_group' => 'home'],
['permission_id' => 'reports_items', 'menu_group' => 'home'],
['permission_id' => 'reports_employees', 'menu_group' => 'home'],
['permission_id' => 'reports_taxes', 'menu_group' => 'home'],
['permission_id' => 'reports_discounts', 'menu_group' => 'home'],
['permission_id' => 'reports_payments', 'menu_group' => 'home'],
['permission_id' => 'reports_receivings', 'menu_group' => 'home'],
['permission_id' => 'reports_inventory', 'menu_group' => 'home']
]
]);
$this->loginAs($employeeId);
$routes = [
'summary_salesExportAll' => '/reports/summary_sales/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_categoriesExportAll' => '/reports/summary_categories/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_expenses_categoriesExportAll' => '/reports/summary_expenses_categories/exportAll/csv/2024-01-01/2024-12-31/sales',
'summary_customersExportAll' => '/reports/summary_customers/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_suppliersExportAll' => '/reports/summary_suppliers/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_itemsExportAll' => '/reports/summary_items/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_employeesExportAll' => '/reports/summary_employees/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_taxesExportAll' => '/reports/summary_taxes/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_sales_taxesExportAll' => '/reports/summary_sales_taxes/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'summary_discountsExportAll' => '/reports/summary_discounts/exportAll/csv/2024-01-01/2024-12-31/sales/all/0',
'summary_paymentsExportAll' => '/reports/summary_payments/exportAll/csv/2024-01-01/2024-12-31',
'inventory_lowExportAll' => '/reports/inventory_low/exportAll/csv',
'inventory_summaryExportAll' => '/reports/inventory_summary/exportAll/csv/all/all',
'detailedItemSalesExportAll' => '/reports/detailed_item_sales/exportAll/csv/2024-01-01/2024-12-31/sales/all/all',
'detailed_salesExportAll' => '/reports/detailed_sales/exportAll/csv/2024-01-01/2024-12-31/sales/all',
'detailed_receivingsExportAll' => '/reports/detailed_receivings/exportAll/csv/2024-01-01/2024-12-31/receiving/all',
'specific_customersExportAll' => '/reports/specific_customers/exportAll/csv/2024-01-01/2024-12-31/1/sales/all',
'specific_employeesExportAll' => '/reports/specific_employees/exportAll/csv/2024-01-01/2024-12-31/1/sales',
'specific_discountsExportAll' => '/reports/specific_discounts/exportAll/csv/2024-01-01/2024-12-31/0/sales/0',
'specific_suppliersExportAll' => '/reports/specific_suppliers/exportAll/csv/2024-01-01/2024-12-31/1/sales',
];
foreach ($routes as $methodName => $path) {
$response = $this->get($path);
$response->assertStatus(200, "Expected 200 for $methodName at $path");
$this->assertStringContainsString(
'attachment',
$response->response()->getHeaderLine('Content-Disposition'),
"Expected attachment Content-Disposition for $methodName at $path"
);
}
}
}