Compare commits

...

2 Commits

Author SHA1 Message Date
Ollama
12146275f4 fix: Fix migration foreign key constraint and remove seeder
- Change employee_id to INT(10) to match employees.person_id type
- Remove seeder call from migration (causes issues in tests)
- Add permission/module inserts directly in migration
- Add proper down() method to drop FK and data
- Remove seed reference from tests

The foreign key was failing because:
1. employee_id was defined as unsigned INT(11)
2. employees.person_id is INT(10) NOT NULL (not unsigned)
3. Seeder call in migration caused multiple runs in tests
2026-03-06 15:05:59 +00:00
Ollama
e45af91e2e feat: Add REST API implementation with API key authentication
Implement comprehensive REST API for OSPOS with the following:

Database:
- Migration for ospos_api_keys table
- Seeder for module permissions

Models:
- ApiKey model with key generation, validation, revocation
- SHA-256 hashing for secure key storage
- Support for key expiration

Filters:
- ApiAuth filter for X-API-Key header authentication
- CSRF exemption for API routes

Controllers:
- Api/BaseController with response helpers and field transformation
- Api/Customers (CRUD + batch delete, suggestions)
- Api/Suppliers (CRUD + batch delete, suggestions)
- Api/Items (CRUD + batch delete, quantities endpoint)
- Api/Inventory (adjustments with set/adjust modes, bulk support)
- ApiKeys (UI controller for key management)

Routes:
- /api/v1/* endpoints with apiauth filter
- /office/api-keys/* endpoints for key management UI

Tests:
- ApiKeyTest for model functionality
- ApiAuthTest for authentication filter

Features:
- camelCase JSON field names (API standard)
- Offset/limit pagination
- Soft delete support
- Permission-based authorization
- Key prefix for UI identification
- Last used timestamp tracking

Refs: #2463, #615, #3789, #3809, #1680, #876, #1959, #157
2026-03-06 14:35:27 +00:00
14 changed files with 1802 additions and 10 deletions

View File

@@ -12,18 +12,10 @@ use CodeIgniter\Filters\InvalidChars;
use CodeIgniter\Filters\PageCache;
use CodeIgniter\Filters\PerformanceMetrics;
use CodeIgniter\Filters\SecureHeaders;
use App\Filters\ApiAuth;
class Filters extends BaseFilters
{
/**
* Configures aliases for Filter classes to
* make reading things nicer and simpler.
*
* @var array<string, class-string|list<class-string>>
*
* [filter_name => classname]
* or [filter_name => [classname1, classname2, ...]]
*/
public array $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
@@ -34,6 +26,7 @@ class Filters extends BaseFilters
'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class,
'apiauth' => ApiAuth::class,
];
/**
@@ -70,7 +63,7 @@ class Filters extends BaseFilters
public array $globals = [
'before' => [
'honeypot',
'csrf' => ['except' => 'login'],
'csrf' => ['except' => ['login', 'api/*']],
'invalidchars',
],
'after' => [

View File

@@ -39,3 +39,50 @@ $routes->add('reports/specific_customers', 'Reports::specific_customer_input');
$routes->add('reports/specific_employees', 'Reports::specific_employee_input');
$routes->add('reports/specific_discounts', 'Reports::specific_discount_input');
$routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input');
$routes->group('office/api-keys', ['filter' => 'session'], static function(RouteCollection $routes): void {
$routes->get('/', 'ApiKeys::index');
$routes->post('generate', 'ApiKeys::generate');
$routes->post('revoke/(:num)', 'ApiKeys::revoke/$1');
$routes->post('regenerate/(:num)', 'ApiKeys::regenerate/$1');
});
$routes->group('api/v1', ['filter' => 'apiauth'], static function(RouteCollection $routes): void {
$routes->get('customers', 'Api\Customers::index');
$routes->get('customers/(:num)', 'Api\Customers::show/$1');
$routes->post('customers', 'Api\Customers::create');
$routes->put('customers/(:num)', 'Api\Customers::update/$1');
$routes->delete('customers/(:num)', 'Api\Customers::delete/$1');
$routes->post('customers/batch-delete', 'Api\Customers::batchDelete');
$routes->get('customers/suggest', 'Api\Customers::suggest');
$routes->get('suppliers', 'Api\Suppliers::index');
$routes->get('suppliers/(:num)', 'Api\Suppliers::show/$1');
$routes->post('suppliers', 'Api\Suppliers::create');
$routes->put('suppliers/(:num)', 'Api\Suppliers::update/$1');
$routes->delete('suppliers/(:num)', 'Api\Suppliers::delete/$1');
$routes->post('suppliers/batch-delete', 'Api\Suppliers::batchDelete');
$routes->get('suppliers/suggest', 'Api\Suppliers::suggest');
$routes->get('items', 'Api\Items::index');
$routes->get('items/(:num)', 'Api\Items::show/$1');
$routes->post('items', 'Api\Items::create');
$routes->put('items/(:num)', 'Api\Items::update/$1');
$routes->delete('items/(:num)', 'Api\Items::delete/$1');
$routes->post('items/batch-delete', 'Api\Items::batchDelete');
$routes->get('items/suggest', 'Api\Items::suggest');
$routes->get('items/(:num)/quantities', 'Api\Items::quantities/$1');
$routes->get('inventory', 'Api\Inventory::index');
$routes->post('inventory', 'Api\Inventory::create');
$routes->post('inventory/bulk', 'Api\Inventory::create');
$routes->get('sales', 'Api\Sales::index');
$routes->get('sales/(:num)', 'Api\Sales::show/$1');
$routes->get('sales/(:num)/items', 'Api\Sales::items/$1');
$routes->get('sales/(:num)/payments', 'Api\Sales::payments/$1');
$routes->get('receivings', 'Api\Receivings::index');
$routes->get('receivings/(:num)', 'Api\Receivings::show/$1');
$routes->get('receivings/(:num)/items', 'Api\Receivings::items/$1');
});

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Controllers\Api;
use App\Models\Employee;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\RESTful\ResourceController;
class BaseController extends ResourceController
{
protected Employee $employee;
protected int $employeeId = 0;
protected $format = 'json';
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->employee = model(Employee::class);
$this->employeeId = $request->employeeId ?? 0;
}
protected function hasPermission(string $moduleId): bool
{
return $this->employee->has_grant($moduleId, $this->employeeId);
}
protected function respondSuccess(array $data = [], int $code = 200, string $message = 'Success'): ResponseInterface
{
$response = ['success' => true];
if ($message) {
$response['message'] = $message;
}
$response = array_merge($response, $data);
return $this->respond($response, $code);
}
protected function respondCreated(array $data = [], string $message = 'Resource created'): ResponseInterface
{
return $this->respondSuccess($data, 201, $message);
}
protected function respondError(string $message, int $code = 400): ResponseInterface
{
return $this->respond([
'success' => false,
'message' => $message
], $code);
}
protected function respondNotFound(string $message = 'Resource not found'): ResponseInterface
{
return $this->respondError($message, 404);
}
protected function respondUnauthorized(string $message = 'Unauthorized'): ResponseInterface
{
return $this->respondError($message, 403);
}
protected function respondValidationError(array $errors): ResponseInterface
{
return $this->respond([
'success' => false,
'message' => 'Validation failed',
'errors' => $errors
], 422);
}
protected function getPagination(): array
{
$offset = (int) ($this->request->getGet('offset') ?? 0);
$limit = (int) ($this->request->getGet('limit') ?? 25);
$limit = min(max($limit, 1), 100);
$offset = max($offset, 0);
return ['offset' => $offset, 'limit' => $limit];
}
protected function getSort(array $allowedFields, string $default = 'id', string $defaultOrder = 'asc'): array
{
$sort = $this->request->getGet('sort') ?? $default;
$order = strtolower($this->request->getGet('order') ?? $defaultOrder);
if (!in_array($sort, $allowedFields)) {
$sort = $default;
}
if (!in_array($order, ['asc', 'desc'])) {
$order = $defaultOrder;
}
return ['sort' => $sort, 'order' => $order];
}
protected function toCamelCase(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
$camelKey = lcfirst(str_replace('_', '', ucwords($key, '_')));
$result[$camelKey] = $value;
}
return $result;
}
protected function toSnakeCase(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$result[$snakeKey] = $value;
}
return $result;
}
protected function transformItem(object|array $item, array $additional = []): array
{
$item = is_object($item) ? (array) $item : $item;
return $this->toCamelCase(array_merge($item, $additional));
}
protected function transformCollection(array $items): array
{
return array_map([$this, 'transformItem'], $items);
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Controllers\Api;
use App\Models\Customer;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
class Customers extends BaseController
{
protected Customer $customerModel;
protected Person $personModel;
protected array $allowedSortFields = ['person_id', 'last_name', 'first_name', 'email', 'company_name'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->customerModel = model(Customer::class);
$this->personModel = model(Person::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$search = $this->request->getGet('search');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'last_name');
$builder = $this->customerModel->builder();
$builder->select('customers.*, people.*');
$builder->join('people', 'people.person_id = customers.person_id');
$builder->where('customers.deleted', 0);
if ($search) {
$builder->groupStart();
$builder->like('people.first_name', $search);
$builder->orLike('people.last_name', $search);
$builder->orLike('people.email', $search);
$builder->orLike('customers.account_number', $search);
$builder->orLike('customers.company_name', $search);
$builder->groupEnd();
}
$total = $builder->countAllResults(false);
$dbSort = $this->mapSortField($sort['sort']);
$builder->orderBy($dbSort, $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$customers = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($customers)
]);
}
public function show($id = null): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$customer = $this->customerModel->get_info($id);
if (empty($customer) || $customer->deleted) {
return $this->respondNotFound('Customer not found');
}
$person = (array) $this->personModel->get_info($id);
$customer = (array) $customer;
$data = array_merge($person, $customer);
return $this->respondSuccess($this->transformItem($data));
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getPost();
}
$data = $this->toSnakeCase($data);
$rules = [
'first_name' => 'required|max_length[255]',
'last_name' => 'required|max_length[255]',
];
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$customerData = array_intersect_key($snakeData, array_flip([
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
]));
$customerData['employee_id'] = $this->employeeId;
$personId = false;
$success = $this->personModel->save_value($personData);
if ($success && isset($personData['person_id'])) {
$personId = $personData['person_id'];
$customerData['person_id'] = $personId;
$success = $this->customerModel->save_value($customerData);
}
if ($success) {
return $this->respondCreated(['id' => $personId], 'Customer created successfully');
}
return $this->respondError('Failed to create customer');
}
public function update($id = null): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$customer = $this->customerModel->get_info($id);
if (empty($customer) || $customer->deleted) {
return $this->respondNotFound('Customer not found');
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getRawInput();
}
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$customerData = array_intersect_key($snakeData, array_flip([
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
]));
if (!empty($personData)) {
$this->personModel->save_value($personData, $id);
}
if (!empty($customerData)) {
$this->customerModel->save_value($customerData, $id);
}
return $this->respondSuccess([], 200, 'Customer updated successfully');
}
public function delete($id = null): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$customer = $this->customerModel->get_info($id);
if (empty($customer) || $customer->deleted) {
return $this->respondNotFound('Customer not found');
}
$success = $this->customerModel->delete($id);
if ($success) {
return $this->respondSuccess([], 200, 'Customer deleted successfully');
}
return $this->respondError('Failed to delete customer');
}
public function batchDelete(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
$ids = $data['ids'] ?? [];
if (empty($ids)) {
return $this->respondError('No customer IDs provided');
}
$success = $this->customerModel->delete_list($ids);
if ($success) {
return $this->respondSuccess([], 200, 'Customers deleted successfully');
}
return $this->respondError('Failed to delete customers');
}
public function suggest(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$term = $this->request->getGet('term');
$limit = (int) ($this->request->getGet('limit') ?? 25);
if (empty($term)) {
return $this->respondSuccess(['suggestions' => []]);
}
$suggestions = $this->customerModel->get_search_suggestions($term, $limit);
return $this->respondSuccess(['suggestions' => $suggestions]);
}
private function mapSortField(string $field): string
{
$map = [
'personId' => 'people.person_id',
'lastName' => 'people.last_name',
'firstName' => 'people.first_name',
'email' => 'people.email',
'companyName' => 'customers.company_name'
];
return $map[$field] ?? 'people.last_name';
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Controllers\Api;
use App\Models\Inventory as InventoryModel;
use App\Models\Item;
use App\Models\Item_quantity;
use CodeIgniter\HTTP\ResponseInterface;
class Inventory extends BaseController
{
protected InventoryModel $inventory;
protected Item $item;
protected Item_quantity $itemQuantity;
protected array $allowedSortFields = ['trans_id', 'trans_date', 'trans_items'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->inventory = model(InventoryModel::class);
$this->item = model(Item::class);
$this->itemQuantity = model(Item_quantity::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$itemId = $this->request->getGet('itemId');
$locationId = $this->request->getGet('locationId');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'trans_date');
$builder = $this->inventory->builder();
if ($itemId) {
$builder->where('trans_items', $itemId);
}
if ($locationId) {
$builder->where('trans_location', $locationId);
}
$total = $builder->countAllResults(false);
$builder->orderBy($sort['sort'], $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$transactions = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($transactions)
]);
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (isset($data['adjustments']) && is_array($data['adjustments'])) {
return $this->bulkAdjust($data['adjustments']);
}
return $this->singleAdjust($data);
}
private function singleAdjust(array $data): ResponseInterface
{
if (empty($data['itemId'])) {
return $this->respondError('itemId is required');
}
if (!isset($data['quantity'])) {
return $this->respondError('quantity is required');
}
$mode = $data['mode'] ?? 'adjust';
if (!in_array($mode, ['adjust', 'set'])) {
return $this->respondError('mode must be "adjust" or "set"');
}
$item = $this->item->find($data['itemId']);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$locationId = $data['locationId'] ?? 1;
$comment = $data['comment'] ?? 'API inventory adjustment';
$quantity = (float) $data['quantity'];
if ($mode === 'set') {
$currentQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
$adjustment = $quantity - $currentQty;
if ($adjustment == 0) {
return $this->respondSuccess([
'itemId' => (int) $data['itemId'],
'locationId' => (int) $locationId,
'newQuantity' => $quantity,
'mode' => $mode
], 200, 'Quantity already at requested level');
}
} else {
$adjustment = $quantity;
}
$invData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $data['itemId'],
'trans_user' => $this->employeeId,
'trans_location' => $locationId,
'trans_comment' => $comment,
'trans_inventory' => $adjustment
];
$this->inventory->insert($invData);
$this->itemQuantity->change_quantity($data['itemId'], $locationId, $adjustment);
$newQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
return $this->respondSuccess([
'itemId' => (int) $data['itemId'],
'locationId' => (int) $locationId,
'adjustment' => $adjustment,
'newQuantity' => $newQty ? (float) $newQty->quantity : 0,
'mode' => $mode
], 200, 'Inventory adjusted successfully');
}
private function bulkAdjust(array $adjustments): ResponseInterface
{
$results = [];
$processed = 0;
$errors = [];
$this->inventory->db->transStart();
foreach ($adjustments as $adjustment) {
$itemId = $adjustment['itemId'] ?? $adjustment['item_id'] ?? null;
if (!$itemId) {
$errors[] = ['itemId' => null, 'success' => false, 'error' => 'itemId is required'];
continue;
}
$item = $this->item->find($itemId);
if (!$item || $item->deleted) {
$errors[] = ['itemId' => $itemId, 'success' => false, 'error' => 'Item not found'];
continue;
}
$mode = $adjustment['mode'] ?? 'adjust';
$locationId = $adjustment['locationId'] ?? $adjustment['location_id'] ?? 1;
$quantity = (float) ($adjustment['quantity'] ?? 0);
$comment = $adjustment['comment'] ?? 'Bulk API inventory adjustment';
if ($mode === 'set') {
$currentQty = $this->itemQuantity->get_item_quantity($itemId, $locationId);
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
$adjustmentQty = $quantity - $currentQty;
} else {
$adjustmentQty = $quantity;
}
$invData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $itemId,
'trans_user' => $this->employeeId,
'trans_location' => $locationId,
'trans_comment' => $comment,
'trans_inventory' => $adjustmentQty
];
$this->inventory->insert($invData);
$this->itemQuantity->change_quantity($itemId, $locationId, $adjustmentQty);
$results[] = ['itemId' => $itemId, 'success' => true];
$processed++;
}
$this->inventory->db->transComplete();
$response = [
'processed' => $processed,
'total' => count($adjustments),
'results' => $results
];
if (!empty($errors)) {
$response['errors'] = $errors;
$response['success'] = false;
$response['message'] = 'Some adjustments failed';
} else {
$response['success'] = true;
$response['message'] = 'All adjustments processed successfully';
}
return $this->respondSuccess($response);
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Controllers\Api;
use App\Models\Item;
use App\Models\Item_quantity;
use CodeIgniter\HTTP\ResponseInterface;
class Items extends BaseController
{
protected Item $itemModel;
protected Item_quantity $itemQuantityModel;
protected array $allowedSortFields = ['item_id', 'name', 'category', 'cost_price', 'unit_price'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->itemModel = model(Item::class);
$this->itemQuantityModel = model(Item_quantity::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$search = $this->request->getGet('search');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'name');
$stockLocation = $this->request->getGet('stockLocation');
$builder = $this->itemModel->builder();
$builder->where('deleted', 0);
if ($search) {
$builder->groupStart();
$builder->like('name', $search);
$builder->orLike('item_number', $search);
$builder->orLike('category', $search);
$builder->orLike('description', $search);
$builder->groupEnd();
}
$total = $builder->countAllResults(false);
$dbSort = $this->mapSortField($sort['sort']);
$builder->orderBy($dbSort, $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$items = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($items)
]);
}
public function show($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
return $this->respondSuccess($this->transformItem($item));
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getPost();
}
$snakeData = $this->toSnakeCase($data);
if (!empty($snakeData['item_number'])) {
if ($this->itemModel->item_number_exists($snakeData['item_number'])) {
return $this->respondError('Item number already exists', 409);
}
}
$itemId = $this->itemModel->save_value($snakeData);
if ($itemId) {
return $this->respondCreated(['id' => $itemId], 'Item created successfully');
}
return $this->respondError('Failed to create item');
}
public function update($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getRawInput();
}
$snakeData = $this->toSnakeCase($data);
$snakeData['item_id'] = $id;
$success = $this->itemModel->save_value($snakeData);
if ($success) {
return $this->respondSuccess([], 200, 'Item updated successfully');
}
return $this->respondError('Failed to update item');
}
public function delete($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$success = $this->itemModel->delete($id);
if ($success) {
return $this->respondSuccess([], 200, 'Item deleted successfully');
}
return $this->respondError('Failed to delete item');
}
public function batchDelete(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
$ids = $data['ids'] ?? [];
if (empty($ids)) {
return $this->respondError('No item IDs provided');
}
$success = $this->itemModel->delete_list($ids);
if ($success) {
return $this->respondSuccess([], 200, 'Items deleted successfully');
}
return $this->respondError('Failed to delete items');
}
public function quantities($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$locations = model('App\Models\Stock_location')->get_all();
$quantities = [];
foreach ($locations as $location) {
$qty = $this->itemQuantityModel->get_item_quantity($id, $location->location_id);
$quantities[] = [
'locationId' => (int) $location->location_id,
'locationName' => $location->location_name,
'quantity' => $qty ? (float) $qty->quantity : 0
];
}
return $this->respondSuccess([
'itemId' => (int) $id,
'quantities' => $quantities
]);
}
public function suggest(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$term = $this->request->getGet('term');
$limit = (int) ($this->request->getGet('limit') ?? 25);
if (empty($term)) {
return $this->respondSuccess(['suggestions' => []]);
}
$suggestions = $this->itemModel->get_search_suggestions($term, $limit);
return $this->respondSuccess(['suggestions' => $suggestions]);
}
private function mapSortField(string $field): string
{
$map = [
'itemId' => 'item_id',
'name' => 'name',
'category' => 'category',
'costPrice' => 'cost_price',
'unitPrice' => 'unit_price'
];
return $map[$field] ?? 'name';
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace App\Controllers\Api;
use App\Models\Supplier;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
class Suppliers extends BaseController
{
protected Supplier $supplierModel;
protected Person $personModel;
protected array $allowedSortFields = ['person_id', 'last_name', 'company_name'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->supplierModel = model(Supplier::class);
$this->personModel = model(Person::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$search = $this->request->getGet('search');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'companyName');
$builder = $this->supplierModel->builder();
$builder->select('suppliers.*, people.*');
$builder->join('people', 'people.person_id = suppliers.person_id');
$builder->where('suppliers.deleted', 0);
if ($search) {
$builder->groupStart();
$builder->like('people.first_name', $search);
$builder->orLike('people.last_name', $search);
$builder->orLike('people.email', $search);
$builder->orLike('suppliers.account_number', $search);
$builder->orLike('suppliers.company_name', $search);
$builder->groupEnd();
}
$total = $builder->countAllResults(false);
$dbSort = $this->mapSortField($sort['sort']);
$builder->orderBy($dbSort, $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$suppliers = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($suppliers)
]);
}
public function show($id = null): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$supplier = $this->supplierModel->get_info($id);
if (empty($supplier) || $supplier->deleted) {
return $this->respondNotFound('Supplier not found');
}
$person = (array) $this->personModel->get_info($id);
$supplier = (array) $supplier;
$data = array_merge($person, $supplier);
return $this->respondSuccess($this->transformItem($data));
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getPost();
}
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$supplierData = array_intersect_key($snakeData, array_flip([
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
]));
$personId = false;
$success = $this->personModel->save_value($personData);
if ($success && isset($personData['person_id'])) {
$personId = $personData['person_id'];
$supplierData['person_id'] = $personId;
$success = $this->supplierModel->save_value($supplierData);
}
if ($success) {
return $this->respondCreated(['id' => $personId], 'Supplier created successfully');
}
return $this->respondError('Failed to create supplier');
}
public function update($id = null): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$supplier = $this->supplierModel->get_info($id);
if (empty($supplier) || $supplier->deleted) {
return $this->respondNotFound('Supplier not found');
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getRawInput();
}
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$supplierData = array_intersect_key($snakeData, array_flip([
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
]));
if (!empty($personData)) {
$this->personModel->save_value($personData, $id);
}
if (!empty($supplierData)) {
$this->supplierModel->save_value($supplierData, $id);
}
return $this->respondSuccess([], 200, 'Supplier updated successfully');
}
public function delete($id = null): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$supplier = $this->supplierModel->get_info($id);
if (empty($supplier) || $supplier->deleted) {
return $this->respondNotFound('Supplier not found');
}
$success = $this->supplierModel->delete($id);
if ($success) {
return $this->respondSuccess([], 200, 'Supplier deleted successfully');
}
return $this->respondError('Failed to delete supplier');
}
public function batchDelete(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
$ids = $data['ids'] ?? [];
if (empty($ids)) {
return $this->respondError('No supplier IDs provided');
}
$success = $this->supplierModel->delete_list($ids);
if ($success) {
return $this->respondSuccess([], 200, 'Suppliers deleted successfully');
}
return $this->respondError('Failed to delete suppliers');
}
public function suggest(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$term = $this->request->getGet('term');
$limit = (int) ($this->request->getGet('limit') ?? 25);
if (empty($term)) {
return $this->respondSuccess(['suggestions' => []]);
}
$suggestions = $this->supplierModel->get_search_suggestions($term, $limit);
return $this->respondSuccess(['suggestions' => $suggestions]);
}
private function mapSortField(string $field): string
{
$map = [
'personId' => 'people.person_id',
'lastName' => 'people.last_name',
'companyName' => 'suppliers.company_name'
];
return $map[$field] ?? 'suppliers.company_name';
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Controllers;
use App\Models\ApiKey;
use App\Models\Employee;
class ApiKeys extends Secure_Controller
{
protected ApiKey $apiKeyModel;
public function __construct()
{
parent::__construct('api_keys');
$this->apiKeyModel = model(ApiKey::class);
}
public function index(): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
echo view('api_keys/manage', [
'keys' => $keys,
'employee_info' => $this->employee->get_logged_in_employee_info()
]);
}
public function generate(): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$name = $this->request->getPost('name');
$expiresAt = $this->request->getPost('expires_at') ?: null;
$apiKey = $this->apiKeyModel->generateKey($employeeId, $name, $expiresAt);
if ($apiKey) {
echo json_encode([
'success' => true,
'message' => lang('Api_keys.key_generated'),
'apiKey' => $apiKey,
'keyPrefix' => substr($apiKey, 0, 12) . '...'
]);
} else {
echo json_encode([
'success' => false,
'message' => lang('Api_keys.key_generation_failed')
]);
}
}
public function revoke(int $apiKeyId): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$success = $this->apiKeyModel->revokeKey($apiKeyId, $employeeId);
echo json_encode([
'success' => $success,
'message' => $success ? lang('Api_keys.key_revoked') : lang('Api_keys.key_revoke_failed')
]);
}
public function regenerate(int $apiKeyId): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$newKey = $this->apiKeyModel->regenerateKey($apiKeyId, $employeeId);
if ($newKey) {
echo json_encode([
'success' => true,
'message' => lang('Api_keys.key_regenerated'),
'apiKey' => $newKey,
'keyPrefix' => substr($newKey, 0, 12) . '...'
]);
} else {
echo json_encode([
'success' => false,
'message' => lang('Api_keys.key_regeneration_failed')
]);
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class ApiKeys extends Migration
{
public function up(): void
{
$this->forge->addField([
'api_key_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true
],
'employee_id' => [
'type' => 'INT',
'constraint' => 10
],
'key_hash' => [
'type' => 'VARCHAR',
'constraint' => 64
],
'key_prefix' => [
'type' => 'VARCHAR',
'constraint' => 12
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true
],
'last_used' => [
'type' => 'DATETIME',
'null' => true
],
'created' => [
'type' => 'DATETIME',
'null' => true
],
'expires_at' => [
'type' => 'DATETIME',
'null' => true
],
'disabled' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0
]
]);
$this->forge->addKey('api_key_id', true);
$this->forge->addKey('employee_id');
$this->forge->addKey('key_hash');
$this->forge->createTable('api_keys', true);
$this->db->query(
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
' ADD CONSTRAINT ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign' .
' FOREIGN KEY (employee_id) REFERENCES ' . $this->db->prefixTable('employees') .
' (person_id) ON DELETE CASCADE ON UPDATE CASCADE'
);
$this->db->query(
'INSERT INTO ' . $this->db->prefixTable('permissions') . ' (permission_id, module_id)' .
" VALUES ('api_keys', 'office') ON DUPLICATE KEY UPDATE permission_id = 'api_keys'"
);
$this->db->query(
'INSERT INTO ' . $this->db->prefixTable('modules') . ' (module_id, name_lang_key, desc_lang_key, sort)' .
" VALUES ('api_keys', 'module_api_keys', 'module_desc_api_keys', 25)" .
" ON DUPLICATE KEY UPDATE module_id = 'module_id'"
);
}
public function down(): void
{
$this->db->query(
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
' DROP FOREIGN KEY ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign'
);
$this->db->query(
'DELETE FROM ' . $this->db->prefixTable('permissions') . " WHERE permission_id = 'api_keys'"
);
$this->db->query(
'DELETE FROM ' . $this->db->prefixTable('modules') . " WHERE module_id = 'api_keys'"
);
$this->forge->dropTable('api_keys', true);
}
}

48
app/Filters/ApiAuth.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use App\Models\ApiKey;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiAuth implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null): mixed
{
$apiKey = $request->getHeaderLine('X-API-Key');
if (empty($apiKey)) {
return $this->unauthorized('API key required');
}
$apiKeyModel = model(ApiKey::class);
$employeeId = $apiKeyModel->validateKey($apiKey);
if (!$employeeId) {
return $this->unauthorized('Invalid or expired API key');
}
$request->employeeId = $employeeId;
Services::set('apiEmployeeId', $employeeId);
return $request;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): mixed
{
return $response;
}
private function unauthorized(string $message): ResponseInterface
{
return Services::response()
->setStatusCode(401)
->setJSON([
'success' => false,
'message' => $message
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
return [
'module_api_keys' => 'API Keys',
'module_desc_api_keys' => 'Manage API access keys for integrations.',
'api_keys' => 'API Keys',
'api_key' => 'API Key',
'generate_key' => 'Generate API Key',
'regenerate_key' => 'Regenerate',
'revoke_key' => 'Revoke',
'key_name' => 'Key Name',
'key_prefix' => 'Key Prefix',
'last_used' => 'Last Used',
'created' => 'Created',
'expires' => 'Expires',
'never' => 'Never',
'disabled' => 'Disabled',
'key_generated' => 'API key generated successfully',
'key_generation_failed' => 'Failed to generate API key',
'key_revoked' => 'API key revoked successfully',
'key_revoke_failed' => 'Failed to revoke API key',
'key_regenerated' => 'API key regenerated successfully',
'key_regeneration_failed' => 'Failed to regenerate API key',
'copy_warning' => 'Copy this key now! It will not be shown again.',
'no_keys' => 'No API keys have been generated yet.',
'confirm_revoke' => 'Are you sure you want to revoke this API key? This action cannot be undone.',
'confirm_regenerate' => 'Are you sure you want to regenerate this API key? The old key will immediately stop working.',
'key_description' => 'API keys allow external applications to access your OSPOS data. Keep your keys secure and never share them publicly.',
];

145
app/Models/ApiKey.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ApiKey extends Model
{
protected $table = 'api_keys';
protected $primaryKey = 'api_key_id';
protected $useAutoIncrement = true;
protected $useSoftDeletes = false;
protected $allowedFields = [
'employee_id',
'key_hash',
'key_prefix',
'name',
'last_used',
'expires_at',
'disabled'
];
protected $useTimestamps = false;
protected $createdField = 'created';
private const KEY_PREFIX = 'ospos_';
private const KEY_BYTES = 32;
public function generateKey(int $employeeId, ?string $name = null, ?string $expiresAt = null): string|false
{
$rawKey = bin2hex(random_bytes(self::KEY_BYTES));
$apiKey = self::KEY_PREFIX . $rawKey;
$keyHash = hash('sha256', $apiKey);
$keyPrefix = substr($apiKey, 0, 12);
$data = [
'employee_id' => $employeeId,
'key_hash' => $keyHash,
'key_prefix' => $keyPrefix,
'name' => $name,
'expires_at' => $expiresAt
];
if ($this->insert($data)) {
return $apiKey;
}
return false;
}
public function validateKey(string $apiKey): int|false
{
if (!str_starts_with($apiKey, self::KEY_PREFIX)) {
return false;
}
if (strlen($apiKey) !== strlen(self::KEY_PREFIX) + (self::KEY_BYTES * 2)) {
return false;
}
$keyHash = hash('sha256', $apiKey);
$builder = $this->builder();
$builder->where('key_hash', $keyHash);
$builder->where('disabled', 0);
$builder->groupStart();
$builder->where('expires_at IS NULL');
$builder->orWhere('expires_at >', date('Y-m-d H:i:s'));
$builder->groupEnd();
$result = $builder->get()->getRow();
if ($result) {
$this->update($result->api_key_id, ['last_used' => date('Y-m-d H:i:s')]);
return (int) $result->employee_id;
}
return false;
}
public function getKeysForEmployee(int $employeeId): array
{
$builder = $this->builder();
$builder->where('employee_id', $employeeId);
$builder->orderBy('created', 'DESC');
return $builder->get()->getResultArray();
}
public function revokeKey(int $apiKeyId, int $employeeId): bool
{
$builder = $this->builder();
$builder->where('api_key_id', $apiKeyId);
$builder->where('employee_id', $employeeId);
return $builder->update(['disabled' => 1]) !== false;
}
public function regenerateKey(int $apiKeyId, int $employeeId): string|false
{
$existingKey = $this->builder()
->getWhere([
'api_key_id' => $apiKeyId,
'employee_id' => $employeeId
])
->getRow();
if (!$existingKey) {
return false;
}
$newKey = $this->generateKey(
$employeeId,
$existingKey->name,
$existingKey->expires_at
);
if ($newKey) {
$this->delete($apiKeyId);
return $newKey;
}
return false;
}
public function cleanupExpired(): int
{
$builder = $this->builder();
$builder->where('disabled', 0);
$builder->where('expires_at <', date('Y-m-d H:i:s'));
$builder->where('expires_at IS NOT NULL');
$expiredKeys = $builder->get()->getResultArray();
$count = 0;
foreach ($expiredKeys as $key) {
if ($this->update($key['api_key_id'], ['disabled' => 1])) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Tests\Filters;
use App\Filters\ApiAuth;
use App\Models\ApiKey;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use Config\Services;
class ApiAuthTest extends CIUnitTestCase
{
protected ApiAuth $filter;
protected ApiKey $apiKeyModel;
protected int $testEmployeeId = 1;
protected function setUp(): void
{
parent::setUp();
$this->filter = new ApiAuth();
$this->apiKeyModel = model(ApiKey::class);
}
public function testBeforeWithNoApiKey(): void
{
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', '');
$result = $this->filter->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertEquals(401, $result->getStatusCode());
$body = json_decode($result->getBody(), true);
$this->assertFalse($body['success']);
$this->assertEquals('API key required', $body['message']);
}
public function testBeforeWithInvalidApiKey(): void
{
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', 'ospos_invalidkey12345678901234567890123456789012345678');
$result = $this->filter->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertEquals(401, $result->getStatusCode());
$body = json_decode($result->getBody(), true);
$this->assertFalse($body['success']);
$this->assertEquals('Invalid or expired API key', $body['message']);
}
public function testBeforeWithValidApiKey(): void
{
$employeeId = $this->testEmployeeId;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', $rawKey);
$result = $this->filter->before($request);
$this->assertInstanceOf(IncomingRequest::class, $result);
$this->assertEquals($employeeId, $result->employeeId);
}
public function testAfterReturnsResponse(): void
{
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$response = new Response(Services::config());
$result = $this->filter->after($request, $response);
$this->assertInstanceOf(Response::class, $result);
}
public function testEmployeeIdSetInService(): void
{
$employeeId = $this->testEmployeeId;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', $rawKey);
$this->filter->before($request);
$this->assertEquals($employeeId, Services::get('apiEmployeeId'));
}
}

191
tests/Models/ApiKeyTest.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
namespace App\Tests\Models;
use App\Models\ApiKey;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
class ApiKeyTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnly = ['api_keys'];
protected $refresh = true;
protected ApiKey $apiKeyModel;
protected function setUp(): void
{
parent::setUp();
$this->apiKeyModel = new ApiKey();
}
public function testGenerateKey(): void
{
$employeeId = 1;
$name = 'Test API Key';
$rawKey = $this->apiKeyModel->generateKey($employeeId, $name);
$this->assertNotFalse($rawKey);
$this->assertStringStartsWith('ospos_', $rawKey);
$this->assertEquals(70, strlen($rawKey)); // ospos_ prefix (6 chars) + 64 hex chars
$keyInDb = $this->apiKeyModel->where('employee_id', $employeeId)
->where('name', $name)
->first();
$this->assertNotNull($keyInDb);
$this->assertEquals(substr($rawKey, 0, 12), $keyInDb->key_prefix);
$this->assertEquals(hash('sha256', $rawKey), $keyInDb->key_hash);
}
public function testValidateKeySuccess(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$validatedEmployeeId = $this->apiKeyModel->validateKey($rawKey);
$this->assertEquals($employeeId, $validatedEmployeeId);
}
public function testValidateKeyInvalidFormat(): void
{
$result = $this->apiKeyModel->validateKey('invalid_key');
$this->assertFalse($result);
$result = $this->apiKeyModel->validateKey('ospos_short');
$this->assertFalse($result);
$result = $this->apiKeyModel->validateKey('otherprefix_' . str_repeat('a', 64));
$this->assertFalse($result);
}
public function testValidateKeyDisabled(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$this->apiKeyModel->update($keyRecord->api_key_id, ['disabled' => 1]);
$result = $this->apiKeyModel->validateKey($rawKey);
$this->assertFalse($result);
}
public function testValidateKeyExpired(): void
{
$employeeId = 1;
$expiresAt = date('Y-m-d H:i:s', strtotime('-1 day'));
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
$result = $this->apiKeyModel->validateKey($rawKey);
$this->assertFalse($result);
}
public function testValidateKeyNotExpired(): void
{
$employeeId = 1;
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 day'));
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
$result = $this->apiKeyModel->validateKey($rawKey);
$this->assertEquals($employeeId, $result);
}
public function testGetKeysForEmployee(): void
{
$employeeId = 1;
$this->apiKeyModel->generateKey($employeeId, 'Key 1');
$this->apiKeyModel->generateKey($employeeId, 'Key 2');
$this->apiKeyModel->generateKey($employeeId, 'Key 3');
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
$this->assertCount(3, $keys);
}
public function testRevokeKey(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, $employeeId);
$this->assertTrue($result);
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
$this->assertEquals(1, $updatedKey->disabled);
$validateResult = $this->apiKeyModel->validateKey($rawKey);
$this->assertFalse($validateResult);
}
public function testRevokeKeyWrongEmployee(): void
{
$employeeId = 1;
$this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, 999);
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
$this->assertEquals(0, $updatedKey->disabled);
}
public function testRegenerateKey(): void
{
$employeeId = 1;
$oldKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$oldKeyId = $keyRecord->api_key_id;
$newKey = $this->apiKeyModel->regenerateKey($oldKeyId, $employeeId);
$this->assertNotFalse($newKey);
$this->assertNotEquals($oldKey, $newKey);
$oldKeyValid = $this->apiKeyModel->validateKey($oldKey);
$this->assertFalse($oldKeyValid);
$newKeyValid = $this->apiKeyModel->validateKey($newKey);
$this->assertEquals($employeeId, $newKeyValid);
$oldKeyExists = $this->apiKeyModel->find($oldKeyId);
$this->assertNull($oldKeyExists);
}
public function testKeyHashNotReversible(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$this->assertNotEquals($rawKey, $keyRecord->key_hash);
$this->assertEquals(64, strlen($keyRecord->key_hash));
}
public function testLastUsedUpdatesOnValidation(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$this->assertNull($keyRecord->last_used);
sleep(1);
$this->apiKeyModel->validateKey($rawKey);
$keyRecord = $this->apiKeyModel->find($keyRecord->api_key_id);
$this->assertNotNull($keyRecord->last_used);
}
}