mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-17 07:50:19 -04:00
Merge branch 'master' into plugin-system-fresh
This commit is contained in:
90 files changed
+4225
-2824
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user