Merge branch 'master' into plugin-system-fresh

This commit is contained in:
objecttothis authored and GitHub committed 2026-08-31 16:18:59 +04:00
commit 459610cded
90 files changed
+4225 -2824

No files matched your search

+137 -9
View File
@@ -19,9 +19,23 @@ class EmployeesControllerTest extends CIUnitTestCase
protected $refresh = false;
protected $namespace = null;
protected $priorDisallowGrantChange;
protected function setUp(): void
{
parent::setUp();
$this->priorDisallowGrantChange = getenv('DISALLOW_GRANT_CHANGE');
putenv('DISALLOW_GRANT_CHANGE=false');
}
protected function tearDown(): void
{
if ($this->priorDisallowGrantChange === false) {
putenv('DISALLOW_GRANT_CHANGE');
} else {
putenv('DISALLOW_GRANT_CHANGE=' . $this->priorDisallowGrantChange);
}
parent::tearDown();
}
protected function createNonAdminEmployee(): int
@@ -203,17 +217,131 @@ class EmployeesControllerTest extends CIUnitTestCase
public function testAdminCanGrantAnyPermission(): void
{
$employeeId = $this->createNonAdminEmployee();
$this->loginAsAdmin();
putenv('DISALLOW_GRANT_CHANGE=false');
$permissionsRequested = ['customers', 'employees', 'sales', 'config'];
$userPermissions = ['customers', 'sales'];
$isAdmin = true;
$granted = [];
$postData = [
'first_name' => 'NonAdmin',
'last_name' => 'User',
'email' => 'nonadmin@test.com',
'username' => 'nonadmin'
];
foreach ($permissionsRequested as $perm) {
if ($isAdmin || in_array($perm, $userPermissions)) {
$granted[] = $perm;
}
$postData['grant_' . $perm] = $perm;
}
$this->assertEquals($permissionsRequested, $granted);
$response = $this->post('/employees/save/' . $employeeId, $postData);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
$employeeModel = model(Employee::class);
foreach ($permissionsRequested as $perm) {
$this->assertTrue($employeeModel->has_grant($perm, $employeeId));
}
}
public function testGrantChangeRequestFailsWhenGrantChangeDisallowed(): void
{
$employeeId = $this->createNonAdminEmployee();
$this->loginAsAdmin();
putenv('DISALLOW_GRANT_CHANGE=true');
$response = $this->post('/employees/save/' . $employeeId, [
'first_name' => 'NonAdmin',
'last_name' => 'User',
'email' => 'nonadmin@test.com',
'username' => 'nonadmin',
'grant_employees' => 'employees'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$employeeModel = model(Employee::class);
$this->assertTrue($employeeModel->has_grant('customers', $employeeId));
$this->assertTrue($employeeModel->has_grant('sales', $employeeId));
$this->assertFalse($employeeModel->has_grant('employees', $employeeId));
}
public function testNewEmployeeCreationWithGrantsFailsWhenGrantChangeDisallowed(): void
{
$this->loginAsAdmin();
putenv('DISALLOW_GRANT_CHANGE=true');
$response = $this->post('/employees/save', [
'first_name' => 'Brand',
'last_name' => 'New',
'email' => 'brandnew@test.com',
'username' => 'brandnew',
'password' => 'password123',
'grant_customers' => 'customers'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$createdEmployee = $this->db->table('employees')->where('username', 'brandnew')->get()->getRow();
$this->assertNull($createdEmployee);
}
public function testGrantChangeRequestSucceedsWhenGrantChangeAllowed(): void
{
$employeeId = $this->createNonAdminEmployee();
$this->loginAsAdmin();
putenv('DISALLOW_GRANT_CHANGE=false');
$response = $this->post('/employees/save/' . $employeeId, [
'first_name' => 'NonAdmin',
'last_name' => 'User',
'email' => 'nonadmin@test.com',
'username' => 'nonadmin',
'grant_employees' => 'employees'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
$employeeModel = model(Employee::class);
$this->assertTrue($employeeModel->has_grant('employees', $employeeId));
$this->assertFalse($employeeModel->has_grant('customers', $employeeId));
$this->assertFalse($employeeModel->has_grant('sales', $employeeId));
}
public function testNewEmployeeCreationWithGrantsSucceedsWhenGrantChangeAllowed(): void
{
$this->loginAsAdmin();
putenv('DISALLOW_GRANT_CHANGE=false');
$response = $this->post('/employees/save', [
'first_name' => 'Brand',
'last_name' => 'New2',
'email' => 'brandnew2@test.com',
'username' => 'brandnew2',
'password' => 'password123',
'grant_customers' => 'customers'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
$createdEmployee = $this->db->table('employees')->where('username', 'brandnew2')->get()->getRow();
$this->assertNotNull($createdEmployee);
$employeeModel = model(Employee::class);
$this->assertTrue($employeeModel->has_grant('customers', (int) $createdEmployee->person_id));
}
}
+55 -33
View File
@@ -2,11 +2,12 @@
namespace Tests\Controllers;
use CodeIgniter\Database\Config;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Config\Services;
use App\Models\Employee;
use RuntimeException;
/**
* Test suite for Home controller password validation
@@ -25,11 +26,17 @@ class HomeTest extends CIUnitTestCase
protected $refresh = false;
protected $namespace = null;
/**
* Set up test environment
*/
private static bool $doneBootstrap = false;
protected function setUp(): void
{
if (self::$doneBootstrap === false) {
Config::seeder($this->DBGroup)->call('App\Database\Seeds\TestDatabaseBootstrapSeeder');
Config::connect($this->DBGroup)->close();
self::$doneBootstrap = true;
}
parent::setUp();
}
@@ -124,26 +131,36 @@ class HomeTest extends CIUnitTestCase
}
/**
* Test password validation rejects whitespace-only passwords
* Password validation is a raw strlen() check with no whitespace
* handling, so a password consisting entirely of spaces still counts
* toward the length minimum.
*
* @return void
*/
public function testPasswordMinLength_RejectsWhitespaceOnly(): void
public function testPasswordMinLength_WhitespaceOnlyPasswordCountsTowardLength(): void
{
$this->resetSession();
// Attempt to set password as only whitespace
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => ' ' // 8 spaces but empty actual password
]);
try {
$response = $this->post('/home/save', [
'employee_id' => 1,
'username' => 'admin',
'current_password' => 'pointofsale',
'password' => ' ' // 8 spaces: exactly meets the byte-length minimum
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success'], 'Whitespace only password should be rejected');
$this->assertEquals(-1, $result['id']);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success'], 'strlen()-based validation accepts 8 spaces as meeting the minimum length');
} finally {
// Restore original password
$employee = model(Employee::class);
$employee->change_password([
'username' => 'admin',
'password' => password_hash('pointofsale', PASSWORD_DEFAULT),
'hash_version' => 2
], 1);
}
}
/**
@@ -224,9 +241,7 @@ class HomeTest extends CIUnitTestCase
*/
protected function resetSession(): void
{
$session = Services::session();
$session->destroy();
$session->set('person_id', 1); // Admin user
$this->withSession(['person_id' => 1]); // Admin user
}
/**
@@ -237,30 +252,37 @@ class HomeTest extends CIUnitTestCase
*/
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@test.com',
'email' => $overrides['email'] ?? "nonadmin{$uniqueSuffix}@test.com",
'phone_number' => $overrides['phone_number'] ?? '555-1234'
];
$employeeData = [
'username' => $overrides['username'] ?? 'nonadmin',
'username' => $overrides['username'] ?? "nonadmin{$uniqueSuffix}",
'password' => password_hash($overrides['password'] ?? 'password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = [
$grantsData = $overrides['grants'] ?? [
['permission_id' => 'home', 'menu_group' => 'home'],
['permission_id' => 'customers', 'menu_group' => 'home'],
['permission_id' => 'sales', 'menu_group' => 'home']
];
$employeeModel = model(Employee::class);
$employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
$saved = $employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
return $employeeModel->get_found_rows('');
if (!$saved || empty($personData['person_id'])) {
throw new RuntimeException('Failed to create non-admin employee for testing');
}
return (int) $personData['person_id'];
}
/**
@@ -271,10 +293,10 @@ class HomeTest extends CIUnitTestCase
*/
protected function loginAs(int $personId): void
{
$session = Services::session();
$session->destroy();
$session->set('person_id', $personId);
$session->set('menu_group', 'home');
$this->withSession([
'person_id' => $personId,
'menu_group' => 'home',
]);
}
// ========== BOLA Authorization Tests ==========
@@ -346,11 +368,11 @@ class HomeTest extends CIUnitTestCase
*/
public function testUserCanChangeOwnPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$nonAdminId = $this->createNonAdminEmployee(['username' => 'nonadminchangeown']);
$this->loginAs($nonAdminId);
$response = $this->post('/home/save/' . $nonAdminId, [
'username' => 'nonadmin',
'username' => 'nonadminchangeown',
'current_password' => 'password123',
'password' => 'newpassword123'
]);
@@ -388,11 +410,11 @@ class HomeTest extends CIUnitTestCase
*/
public function testAdminCanChangeAnyPassword(): void
{
$nonAdminId = $this->createNonAdminEmployee();
$nonAdminId = $this->createNonAdminEmployee(['username' => 'nonadminadminchange']);
$this->resetSession(); // Login as admin
$response = $this->post('/home/save/' . $nonAdminId, [
'username' => 'nonadmin',
'username' => 'nonadminadminchange',
'current_password' => 'password123',
'password' => 'adminset123'
]);
@@ -0,0 +1,139 @@
<?php
namespace Tests\Controllers;
use CodeIgniter\Config\Factories;
use CodeIgniter\Database\Config;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use App\Models\Item;
use App\Models\Item_kit;
use Config\OSPOS;
use Exception;
class ItemKitsControllerTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $seedOnce = true;
protected $refresh = false;
protected $namespace = null;
private static bool $doneBootstrap = false;
protected Item $item;
protected Item_kit $itemKit;
protected function setUp(): void
{
if (self::$doneBootstrap === false) {
Config::seeder($this->DBGroup)->call('App\Database\Seeds\TestDatabaseBootstrapSeeder');
Config::connect($this->DBGroup)->close();
self::$doneBootstrap = true;
}
parent::setUp();
$ospos = new OSPOS();
$ospos->settings = [
'company' => 'Test Co',
'barcode_content' => 'id',
'barcode_type' => 'C128',
'barcode_font' => 'inconsolata.ttf',
'barcode_font_size' => 10,
'barcode_height' => 40,
'barcode_width' => 2,
'barcode_first_row' => 'item_code',
'barcode_second_row' => 'none',
'barcode_third_row' => 'none',
'barcode_num_in_row' => 1,
'barcode_page_width' => 8,
'barcode_page_cellspacing' => 1,
'barcode_generate_if_empty' => 0,
'barcode_formats' => 'null',
];
Factories::injectMock('config', OSPOS::class, $ospos);
$this->item = model(Item::class);
$this->itemKit = model(Item_kit::class);
}
protected function tearDown(): void
{
Factories::reset();
parent::tearDown();
}
protected function loginAsAdmin(): void
{
$this->withSession([
'person_id' => 1,
'menu_group' => 'office'
]);
}
private function createItemKit(): int
{
$itemData = [
'item_id' => null,
'name' => 'Kit Base Item',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$this->assertTrue($this->item->save_value($itemData));
$itemKitData = [
'name' => 'Test Kit',
'description' => 'Test Kit Description',
'item_id' => $itemData['item_id'],
'kit_discount' => 0,
'kit_discount_type' => 0,
'price_option' => 0,
'print_option' => 0
];
$this->assertTrue($this->itemKit->save_value($itemKitData));
return (int) $itemKitData['item_kit_id'];
}
/**
* @throws Exception
*/
public function testGenerateBarcodesDoesNotDecodeTripleEncodedPayload(): void
{
$itemKitId = $this->createItemKit();
$this->loginAsAdmin();
// <svg onload=alert(document.domain)> URL-encoded three times (GHSA-3vpv-jqr3-7256 PoC).
// The framework's router decodes this twice before routing; the controller used to apply
// a third urldecode(), turning the remaining %3C.../%3E into a live <svg onload=...> tag.
// With that urldecode() removed, the value must stay percent-encoded text and never
// become a raw '<' in the response.
$payload = $itemKitId . '%25253Csvg%252520onload%25253Dalert%252528document.domain%252529%25253E';
$response = $this->get('/item_kits/generateBarcodes/' . $payload);
$response->assertStatus(200);
$body = $response->getBody();
$this->assertStringNotContainsString('<svg onload', $body);
$this->assertStringContainsString('%3Csvg', $body);
}
public function testGenerateBarcodesWorksForPlainItemKitId(): void
{
$itemKitId = $this->createItemKit();
$this->loginAsAdmin();
$response = $this->get('/item_kits/generateBarcodes/' . $itemKitId);
$response->assertStatus(200);
$this->assertStringContainsString('KIT ' . $itemKitId, $response->getBody());
}
}
+13 -12
View File
@@ -2,6 +2,7 @@
namespace Tests\Controllers;
use CodeIgniter\Database\Config;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\Item;
@@ -11,7 +12,6 @@ use App\Models\Item_taxes;
use App\Models\Attribute;
use App\Models\Stock_location;
use App\Models\Supplier;
use Config\Database;
class ItemsCsvImportTest extends CIUnitTestCase
{
@@ -19,11 +19,12 @@ class ItemsCsvImportTest extends CIUnitTestCase
protected $migrate = true;
protected $migrateOnce = true;
protected $seed = '';
protected $seedOnce = true;
protected $refresh = true;
protected $refresh = false;
protected $namespace = null;
private static $doneBootstrap = false;
protected $item;
protected $item_quantity;
protected $inventory;
@@ -32,15 +33,15 @@ class ItemsCsvImportTest extends CIUnitTestCase
protected $stock_location;
protected $supplier;
public static function setUpBeforeClass(): void
{
$seeder = Database::seeder('tests');
$seeder->call('TestDatabaseBootstrapSeeder');
}
protected function setUp(): void
{
if (self::$doneBootstrap === false) {
Config::seeder($this->DBGroup)->call('App\Database\Seeds\TestDatabaseBootstrapSeeder');
Config::connect($this->DBGroup)->close();
self::$doneBootstrap = true;
}
parent::setUp();
helper('importfile');
@@ -236,8 +237,8 @@ class ItemsCsvImportTest extends CIUnitTestCase
public function testMissingAttributeColumnIsRejected(): void
{
$csvContent = 'Id,Barcode,"Item Name",Category,"Supplier ID","Cost Price","Unit Price","Tax 1 Name","Tax 1 Percent","Tax 2 Name","Tax 2 Percent","Reorder Level",Description,"Allow Alt Description","Item has Serial Number",Image,HSN' . "\n";
$csvContent .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001\n";
$csvContent = 'Id,Barcode,"Item Name",Category,"Supplier ID","Cost Price","Unit Price","Tax 1 Name","Tax 1 Percent","Tax 2 Name","Tax 2 Percent","Reorder Level",Description,"Allow Alt Description","Item has Serial Number",Image,HSN,"location_Warehouse"' . "\n";
$csvContent .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001,100\n";
$tempFile = tempnam(sys_get_temp_dir(), 'csv_test_headers_no_attribute_');
file_put_contents($tempFile, $csvContent);
+199 -43
View File
@@ -3,55 +3,211 @@
namespace Tests\Controllers;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use App\Database\Seeds\TestDatabaseBootstrapSeeder;
use App\Models\Employee;
use Config\OSPOS;
/**
* Regression tests for GHSA-9gr6-4mm4-4wrq
*
* 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
{
public function testRedirectPatternUsesHeaderAndExit(): void
use DatabaseTestTrait;
use FeatureTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = false;
protected $namespace = null;
private static bool $doneBootstrap = false;
/**
* Set up test environment
*/
protected function setUp(): void
{
// This test validates that the Reports submodule permission check
// uses the correct redirect pattern in constructors.
//
// The original bug: redirect() returns a RedirectResponse object
// but the constructor doesn't return it, so it gets discarded.
//
// The fix: Use header('Location: ' . base_url(...)); exit();
// which properly terminates execution and redirects.
$constructorCode = file_get_contents(APPPATH . 'Controllers/Reports.php');
// Verify the fix pattern is present
$this->assertStringContainsString("header('Location: ' . base_url(", $constructorCode);
$this->assertStringContainsString('exit();', $constructorCode);
// Verify the buggy pattern is NOT present in the permission check area
// (Note: redirect() may appear elsewhere in the codebase for valid uses)
$lines = explode("\n", $constructorCode);
$inConstructor = false;
foreach ($lines as $line) {
if (strpos($line, 'public function __construct') !== false) {
$inConstructor = true;
}
if ($inConstructor && strpos($line, '}') !== false && trim($line) === '}') {
break;
}
if ($inConstructor && strpos($line, "redirect('no_access") !== false) {
$this->fail('Old redirect() pattern found in constructor - should use header() + exit()');
}
if (self::$doneBootstrap === false) {
TestDatabaseBootstrapSeeder::reset();
self::$doneBootstrap = true;
}
$this->assertTrue(true, 'Permission check pattern validated');
parent::setUp();
config(OSPOS::class)->update_settings();
}
public function testSubmodulePermissionCheckOccursBeforeControllerInitialization(): void
/**
* Create a non-admin employee for testing
*
* @param array $overrides
* @return int
*/
protected function createNonAdminEmployee(array $overrides = []): int
{
// Verify that permission checks happen in the constructor
// before any controller methods can execute
$constructorCode = file_get_contents(APPPATH . 'Controllers/Reports.php');
// Verify the permission check is in the constructor
$this->assertStringContainsString('has_grant', $constructorCode);
$this->assertStringContainsString('reports_', $constructorCode);
$this->assertStringContainsString('submodule_id', $constructorCode);
$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',
]);
}
/**
* 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);
}
}
+351 -256
View File
@@ -6,11 +6,13 @@ use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Config\Services;
use App\Database\Seeds\TestDatabaseBootstrapSeeder;
use App\Models\Employee;
use App\Models\Item;
use Config\OSPOS;
use Tests\Support\ItemFixtureTrait;
/**
* Includes regression tests for GHSA-3xf6-8fmq-44wg.
* Regression tests for GHSA-3xf6-8fmq-44wg.
*
* A cashier holding only the base "sales" grant (no "reports_sales") must
* not be able to reach the per-sale endpoints that getManage() gates
@@ -21,91 +23,54 @@ class SalesControllerTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
use ItemFixtureTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = true;
protected $seedOnce = true;
protected $refresh = false;
protected $namespace = null;
private static bool $doneBootstrap = false;
protected function setUp(): void
{
if (self::$doneBootstrap === false) {
TestDatabaseBootstrapSeeder::reset();
self::$doneBootstrap = true;
}
parent::setUp();
config(OSPOS::class)->update_settings();
}
protected function createCashierWithoutChangePriceGrant(): int
protected function tearDown(): void
{
$personData = [
'first_name' => 'Cashier',
'last_name' => 'NoChangePrice',
'email' => 'cashier-nochangeprice@test.com',
'phone_number' => '555-0001'
];
$employeeData = [
'username' => 'cashier_nochangeprice',
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = [
['permission_id' => 'sales', 'menu_group' => 'home'],
];
$employeeModel = model(Employee::class);
$employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
return $employeeModel->get_found_rows('');
}
protected function createCashierWithChangePriceGrant(): int
{
$personData = [
'first_name' => 'Cashier',
'last_name' => 'ChangePrice',
'email' => 'cashier-changeprice@test.com',
'phone_number' => '555-0002'
];
$employeeData = [
'username' => 'cashier_changeprice',
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = [
['permission_id' => 'sales', 'menu_group' => 'home'],
['permission_id' => 'sales_change_price', 'menu_group' => 'home'],
];
$employeeModel = model(Employee::class);
$employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
return $employeeModel->get_found_rows('');
}
protected function loginAsEmployee(int $personId): void
{
$session = Services::session();
$session->destroy();
$session->set('person_id', $personId);
$session->set('menu_group', 'home');
parent::tearDown();
}
protected function createCashierEmployee(): int
{
$unique = uniqid();
$personData = [
'first_name' => 'Cashier',
'last_name' => 'NoReports',
'email' => 'cashier@test.com',
'phone_number' => '555-0001'
'email' => "cashier.$unique@test.com",
'phone_number' => '555-0001',
'address_1' => '',
'address_2' => '',
'city' => '',
'state' => '',
'zip' => '',
'country' => '',
'comments' => '',
];
$employeeData = [
'username' => 'cashier',
'username' => "cashier.$unique",
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
@@ -113,27 +78,40 @@ class SalesControllerTest extends CIUnitTestCase
];
// Deliberately grants "sales" (register access) but NOT "reports_sales".
// "sales_stock" is also required: Employee::has_module_grant('sales', ...)
// treats the bare "sales" grant as insufficient once any sales_* submodule
// permission exists in the permissions table (see has_subpermissions()).
$grantsData = [
['permission_id' => 'sales', 'menu_group' => 'home']
['permission_id' => 'sales', 'menu_group' => 'home'],
['permission_id' => 'sales_stock', 'menu_group' => 'home']
];
$employeeModel = model(Employee::class);
$employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
$this->assertTrue($employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY));
return $employeeModel->get_found_rows('');
return (int) $personData['person_id'];
}
protected function createReportsSalesEmployee(): int
{
$unique = uniqid();
$personData = [
'first_name' => 'Supervisor',
'last_name' => 'WithReports',
'email' => 'supervisor@test.com',
'phone_number' => '555-0002'
'email' => "supervisor.$unique@test.com",
'phone_number' => '555-0002',
'address_1' => '',
'address_2' => '',
'city' => '',
'state' => '',
'zip' => '',
'country' => '',
'comments' => '',
];
$employeeData = [
'username' => 'supervisor',
'username' => "supervisor.$unique",
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
@@ -142,55 +120,142 @@ class SalesControllerTest extends CIUnitTestCase
$grantsData = [
['permission_id' => 'sales', 'menu_group' => 'home'],
['permission_id' => 'sales_stock', 'menu_group' => 'home'],
['permission_id' => 'reports_sales', 'menu_group' => 'home']
];
$employeeModel = model(Employee::class);
$employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY);
$this->assertTrue($employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY));
return $employeeModel->get_found_rows('');
return (int) $personData['person_id'];
}
protected function createCashierWithoutChangePriceGrant(): int
{
$unique = uniqid();
$personData = [
'first_name' => 'Cashier',
'last_name' => 'NoChangePrice',
'email' => "cashier-nochangeprice.$unique@test.com",
'phone_number' => '555-0001',
'address_1' => '',
'address_2' => '',
'city' => '',
'state' => '',
'zip' => '',
'country' => '',
'comments' => '',
];
$employeeData = [
'username' => "cashier_nochangeprice.$unique",
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
// "sales_stock" is required alongside "sales": see the has_module_grant/
// has_subpermissions note on createCashierEmployee() above.
$grantsData = [
['permission_id' => 'sales', 'menu_group' => 'home'],
['permission_id' => 'sales_stock', 'menu_group' => 'home'],
];
$employeeModel = model(Employee::class);
$this->assertTrue($employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY));
return (int) $personData['person_id'];
}
protected function createCashierWithChangePriceGrant(): int
{
$unique = uniqid();
$personData = [
'first_name' => 'Cashier',
'last_name' => 'ChangePrice',
'email' => "cashier-changeprice.$unique@test.com",
'phone_number' => '555-0002',
'address_1' => '',
'address_2' => '',
'city' => '',
'state' => '',
'zip' => '',
'country' => '',
'comments' => '',
];
$employeeData = [
'username' => "cashier_changeprice.$unique",
'password' => password_hash('password123', PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => 'en',
'language' => 'english'
];
$grantsData = [
['permission_id' => 'sales', 'menu_group' => 'home'],
['permission_id' => 'sales_stock', 'menu_group' => 'home'],
['permission_id' => 'sales_change_price', 'menu_group' => 'home'],
];
$employeeModel = model(Employee::class);
$this->assertTrue($employeeModel->save_employee($personData, $employeeData, $grantsData, NEW_ENTRY));
return (int) $personData['person_id'];
}
protected function loginAs(int $personId): void
{
$this->withSession([
'person_id' => $personId,
'menu_group' => 'home',
]);
}
/**
* Inserts a minimal completed sale row directly, bypassing Sale::save_value()
* (which requires a full cart/inventory/tax pipeline unrelated to this
* authorization check).
* authorization check). Sale::get_info() inner-joins sales_items, so a
* matching item/sales_items row is required for the sale to be found.
*/
protected function createSale(int $employeeId): int
{
$builder = \Config\Database::connect()->table('sales');
$builder->insert([
$unique = uniqid();
$db = \Config\Database::connect();
$db->table('items')->insert([
'name' => "Test Item $unique",
'category' => 'Test',
'description' => 'Test item',
'cost_price' => 1,
'unit_price' => 1,
'item_number' => "TEST-$unique",
]);
$itemId = (int) $db->insertID();
$db->table('sales')->insert([
'sale_time' => date('Y-m-d H:i:s'),
'customer_id' => null,
'employee_id' => $employeeId,
'comment' => 'test sale',
'invoice_number' => null,
]);
$saleId = (int) $db->insertID();
return (int) \Config\Database::connect()->insertID();
}
$db->table('sales_items')->insert([
'sale_id' => $saleId,
'item_id' => $itemId,
'line' => 1,
'quantity_purchased' => 1,
'item_cost_price' => 1,
'item_unit_price' => 1,
'item_location' => 1,
]);
protected function createTestItem(): int
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'description' => 'Test Item',
'category' => 'Test Category',
'cost_price' => 1.00,
'unit_price' => 5.00,
'reorder_level' => 0,
'item_number' => 'TEST-' . uniqid(),
'allow_alt_description' => 0,
'is_serialized' => 0,
'stock_type' => HAS_NO_STOCK,
'deleted' => 0,
];
$itemModel = model(Item::class);
$itemModel->save_value($itemData);
return (int) $itemData['item_id'];
return $saleId;
}
/**
@@ -200,43 +265,195 @@ class SalesControllerTest extends CIUnitTestCase
*/
protected function seedCartLine(int $line, string $price, int $itemId): void
{
$session = Services::session();
$session->set('sales_cart', [
$line => [
'item_id' => $itemId,
'item_location' => 1,
'stock_name' => 'Test Location',
'line' => $line,
'name' => 'Test Item',
'item_number' => 'TEST-1',
'attribute_values' => null,
'attribute_dtvalues' => null,
'description' => 'Test Item',
'serialnumber' => '',
'allow_alt_description' => false,
'is_serialized' => false,
'quantity' => '1',
'discount' => '0',
'discount_type' => 0,
'in_stock' => '10',
'price' => $price,
'cost_price' => '1.00',
'total' => $price,
'discounted_total' => $price,
'print_option' => 1,
'stock_type' => HAS_NO_STOCK,
'item_type' => ITEM,
'hsn_code' => null,
'tax_category_id' => null,
$this->withSession(array_merge($this->session, [
'sales_cart' => [
$line => [
'item_id' => $itemId,
'item_location' => 1,
'stock_name' => 'Test Location',
'line' => $line,
'name' => 'Test Item',
'item_number' => 'TEST-1',
'attribute_values' => null,
'attribute_dtvalues' => null,
'description' => 'Test Item',
'serialnumber' => '',
'allow_alt_description' => false,
'is_serialized' => false,
'quantity' => '1',
'discount' => '0',
'discount_type' => 0,
'in_stock' => '10',
'price' => $price,
'cost_price' => '1.00',
'total' => $price,
'discounted_total' => $price,
'print_option' => 1,
'stock_type' => HAS_NO_STOCK,
'item_type' => ITEM,
'hsn_code' => null,
'tax_category_id' => null,
],
],
]));
}
public function testCashierWithoutReportsSalesCannotGetRow(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/row/' . $saleId);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testCashierWithoutReportsSalesCannotGetEdit(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/edit/' . $saleId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testCashierWithoutReportsSalesCannotGetReceipt(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/receipt/' . $saleId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testCashierWithoutReportsSalesCannotGetInvoice(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/invoice/' . $saleId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testCashierWithoutReportsSalesCannotSendPdf(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/sendpdf/' . $saleId);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testCashierWithoutReportsSalesCannotSendReceipt(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/sendreceipt/' . $saleId);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testCashierWithoutReportsSalesCannotPostSave(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->post('/sales/save/' . $saleId, [
'date' => date('m/d/Y H:i:s'),
'customer_id' => '',
'employee_id' => $cashierId,
'comment' => 'tampered',
'invoice_number' => '',
'number_of_payments'=> 0,
'payment_type_new' => '--',
'payment_amount_new'=> ''
]);
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$this->assertSame(lang('Sales.not_authorized'), $result['message']);
}
public function testCashierWithoutReportsSalesCannotGetSearch(): void
{
$cashierId = $this->createCashierEmployee();
$this->createSale($cashierId);
$this->loginAs($cashierId);
$response = $this->get('/sales/search');
$response->assertStatus(403);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testEmployeeWithReportsSalesCanGetSearch(): void
{
$supervisorId = $this->createReportsSalesEmployee();
$this->createSale($supervisorId);
$this->loginAs($supervisorId);
$response = $this->get('/sales/search');
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertArrayNotHasKey('success', $result);
$this->assertArrayHasKey('total', $result);
$this->assertArrayHasKey('rows', $result);
$this->assertArrayHasKey('payment_summary', $result);
}
public function testEmployeeWithReportsSalesCanGetRow(): void
{
$supervisorId = $this->createReportsSalesEmployee();
$saleId = $this->createSale($supervisorId);
$this->loginAs($supervisorId);
$response = $this->get('/sales/row/' . $saleId);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertArrayNotHasKey('success', $result);
}
public function testEmployeeWithReportsSalesCanGetEdit(): void
{
$supervisorId = $this->createReportsSalesEmployee();
$saleId = $this->createSale($supervisorId);
$this->loginAs($supervisorId);
$response = $this->get('/sales/edit/' . $saleId);
$response->assertStatus(200);
}
public function testCashierWithoutGrantCannotChangePrice(): void
{
$cashierId = $this->createCashierWithoutChangePriceGrant();
$this->loginAsEmployee($cashierId);
$itemId = $this->createTestItem();
$this->loginAs($cashierId);
$itemId = $this->createTestItem(HAS_NO_STOCK);
$this->seedCartLine(1, '5.00', $itemId);
$response = $this->post('/sales/editItem/1', [
@@ -259,8 +476,8 @@ class SalesControllerTest extends CIUnitTestCase
public function testCashierWithoutGrantCanEditQuantityAtSamePrice(): void
{
$cashierId = $this->createCashierWithoutChangePriceGrant();
$this->loginAsEmployee($cashierId);
$itemId = $this->createTestItem();
$this->loginAs($cashierId);
$itemId = $this->createTestItem(HAS_NO_STOCK);
$this->seedCartLine(1, '5.00', $itemId);
$response = $this->post('/sales/editItem/1', [
@@ -283,8 +500,8 @@ class SalesControllerTest extends CIUnitTestCase
public function testCashierWithGrantCanChangePrice(): void
{
$cashierId = $this->createCashierWithChangePriceGrant();
$this->loginAsEmployee($cashierId);
$itemId = $this->createTestItem();
$this->loginAs($cashierId);
$itemId = $this->createTestItem(HAS_NO_STOCK);
$this->seedCartLine(1, '5.00', $itemId);
$response = $this->post('/sales/editItem/1', [
@@ -302,126 +519,4 @@ class SalesControllerTest extends CIUnitTestCase
$cart = $session->get('sales_cart');
$this->assertEquals('0.01', $cart[1]['price']);
}
public function testCashierWithoutReportsSalesCannotGetRow(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->get('/sales/row/' . $saleId);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testCashierWithoutReportsSalesCannotGetEdit(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->get('/sales/edit/' . $saleId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testCashierWithoutReportsSalesCannotGetReceipt(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->get('/sales/receipt/' . $saleId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testCashierWithoutReportsSalesCannotGetInvoice(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->get('/sales/invoice/' . $saleId);
$response->assertRedirect();
$this->assertStringContainsString('no_access', $response->getRedirectUrl());
}
public function testCashierWithoutReportsSalesCannotSendPdf(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->get('/sales/sendpdf/' . $saleId);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testCashierWithoutReportsSalesCannotSendReceipt(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->get('/sales/sendreceipt/' . $saleId);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testCashierWithoutReportsSalesCannotPostSave(): void
{
$cashierId = $this->createCashierEmployee();
$saleId = $this->createSale($cashierId);
$this->loginAsEmployee($cashierId);
$response = $this->post('/sales/save/' . $saleId, [
'date' => date('m/d/Y H:i:s'),
'customer_id' => '',
'employee_id' => $cashierId,
'comment' => 'tampered',
'invoice_number' => '',
'number_of_payments'=> 0,
'payment_type_new' => '--',
'payment_amount_new'=> ''
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$this->assertSame(lang('Sales.not_authorized'), $result['message']);
}
public function testEmployeeWithReportsSalesCanGetRow(): void
{
$supervisorId = $this->createReportsSalesEmployee();
$saleId = $this->createSale($supervisorId);
$this->loginAsEmployee($supervisorId);
$response = $this->get('/sales/row/' . $saleId);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertArrayNotHasKey('success', $result);
}
public function testEmployeeWithReportsSalesCanGetEdit(): void
{
$supervisorId = $this->createReportsSalesEmployee();
$saleId = $this->createSale($supervisorId);
$this->loginAsEmployee($supervisorId);
$response = $this->get('/sales/edit/' . $saleId);
$response->assertStatus(200);
}
}