diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a75a8df7a0..64e47bc785 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,9 +2,9 @@ name: PHPUnit Tests
on:
push:
- branches: [ master, main ]
+ branches: [ master, main, test_api ]
pull_request:
- branches: [ master, main ]
+ branches: [ master, main, test_api ]
env:
DB_NAME: facturascripts_test
@@ -161,6 +161,31 @@ jobs:
EOF
fi
+ - name: Add api to file
+ run: |
+ echo "define('FS_API_KEY', 'prueba');" >> config.php
+ - name: Start PHP built-in server
+ run: |
+ php -S 127.0.0.1:8000 -t . &
+ sleep 2
+
+ - name: Seed database
+ run: |
+ if [ "${{ matrix.database.type }}" = "mysql" ] || [ "${{ matrix.database.type }}" = "mariadb" ]; then
+ mysql --host=${{ env.DB_HOST }} --user=${{ env.DB_USER }} --password=${{ env.DB_PASS }} ${{ env.DB_NAME }} < seed.sql
+ else
+ PGPASSWORD=${{ env.DB_PASS }} psql -h ${{ env.DB_HOST }} -U ${{ env.DB_USER }} -d ${{ env.DB_NAME }} -f seed.sql
+ fi
+
+ - name: Run PHPUnit API tests
+ run: |
+ if [ -f phpunit-api.xml ] || [ -f phpunit-api.xml.dist ]; then
+ vendor/bin/phpunit --configuration phpunit-api.xml --coverage-text --coverage-clover=coverage.xml
+ else
+ echo "No PHPUnit configuration found. Running with default settings..."
+ vendor/bin/phpunit --bootstrap vendor/autoload.php tests/
+ fi
+
- name: Run PHPUnit tests
run: |
if [ -f phpunit.xml ] || [ -f phpunit.xml.dist ]; then
diff --git a/Core/Model/ApiAccess.php b/Core/Model/ApiAccess.php
index 13c1c0366c..cac29cd497 100644
--- a/Core/Model/ApiAccess.php
+++ b/Core/Model/ApiAccess.php
@@ -131,6 +131,26 @@ public static function primaryColumn(): string
return 'id';
}
+ /**
+ * Update HTTP method permissions for this API resource and save the changes.
+ *
+ * @param bool $get Whether GET is allowed.
+ * @param bool $post Whether POST is allowed.
+ * @param bool $put Whether PUT is allowed.
+ * @param bool $delete Whether DELETE is allowed.
+ *
+ * @return bool True if saved successfully, false otherwise.
+ */
+ public function setAllowed(bool $get, bool $post, bool $put, bool $delete): bool
+ {
+ $this->allowget = $get;
+ $this->allowpost = $post;
+ $this->allowput = $put;
+ $this->allowdelete = $delete;
+
+ return $this->save();
+ }
+
public static function tableName(): string
{
return 'api_access';
diff --git a/Core/Model/ApiKey.php b/Core/Model/ApiKey.php
index 1d4163d0d4..c32aa4242d 100644
--- a/Core/Model/ApiKey.php
+++ b/Core/Model/ApiKey.php
@@ -19,7 +19,9 @@
namespace FacturaScripts\Core\Model;
+use FacturaScripts\Core\Base\DataBase\DataBaseWhere;
use FacturaScripts\Core\Tools;
+use FacturaScripts\Dinamic\Model\ApiAccess;
/**
* ApiKey model to manage the connection tokens through the api
@@ -53,6 +55,38 @@ class ApiKey extends Base\ModelClass
/** @var string */
public $nick;
+ /**
+ * Adds a new API access entry for the given resource with the specified permissions.
+ *
+ * If the resource already exists for this API key, no changes are made.
+ *
+ * @param string $resource Resource name to grant access to.
+ * @param bool $state Initial permission state (applied to all methods).
+ *
+ * @return bool True if created or already exists, false on failure.
+ */
+ public function addResourceAccess(string $resource, bool $state = false): bool
+ {
+ if (false !== $this->getResourceAccess($resource)) {
+ return true; // already exists
+ }
+
+ $apiAccess = new ApiAccess();
+
+ $apiAccess->idapikey = $this->id;
+ $apiAccess->resource = $resource;
+ $apiAccess->allowdelete = $state;
+ $apiAccess->allowget = $state;
+ $apiAccess->allowpost = $state;
+ $apiAccess->allowput = $state;
+
+ if (false === $apiAccess->save()) {
+ return false;
+ }
+
+ return true;
+ }
+
public function clear()
{
parent::clear();
@@ -62,6 +96,31 @@ public function clear()
$this->fullaccess = false;
}
+ /**
+ * Retrieves the API access entry for the specified resource.
+ *
+ * Use addResourceAccess() first if the resource does not exist.
+ *
+ * @param string $resource Resource name to look up.
+ *
+ * @return ApiAccess|bool The ApiAccess object if found, false otherwise.
+ */
+ public function getResourceAccess(string $resource): ApiAccess|bool
+ {
+ $apiAccess = new ApiAccess();
+
+ $where = [
+ new DataBaseWhere('idapikey', $this->id),
+ new DataBaseWhere('resource', $resource)
+ ];
+
+ if ($apiAccess->loadFromCode('', $where)) {
+ return $apiAccess;
+ } else {
+ return false;
+ }
+ }
+
public static function primaryColumn(): string
{
return 'id';
diff --git a/Core/Template/ApiController.php b/Core/Template/ApiController.php
index 18b2548cd4..907607c982 100644
--- a/Core/Template/ApiController.php
+++ b/Core/Template/ApiController.php
@@ -130,7 +130,7 @@ private function clientHasManyIncidents(): bool
$ipCount++;
}
}
- return $ipCount > self::MAX_INCIDENT_COUNT;
+ return $ipCount >= self::MAX_INCIDENT_COUNT;
}
private function getIpList(): array
diff --git a/Test/API/CRUDTest.php b/Test/API/CRUDTest.php
new file mode 100644
index 0000000000..e62bca8852
--- /dev/null
+++ b/Test/API/CRUDTest.php
@@ -0,0 +1,99 @@
+startAPIServer();
+ }
+
+ public function testListResources()
+ {
+
+ $result = $this->makeGETCurl();
+
+ $expected = [ 'resources' => $this->getResourcesList() ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ }
+
+ public function testCreateData(){
+ $form = [
+ 'coddivisa' => '123',
+ 'descripcion' => 'Divisa 123',
+ ];
+
+
+ $result = $this->makePOSTCurl("divisas", $form);
+
+
+ $expected = [
+ 'ok' => 'Registro actualizado correctamente.',
+ 'data' => [
+ 'coddivisa' => '123',
+ 'codiso' => null,
+ 'descripcion' => 'Divisa 123',
+ 'simbolo' => '?',
+ 'tasaconv' => 1,
+ 'tasaconvcompra' => 1
+ ]
+ ];
+
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+
+ public function testUpdateData(){
+ $result = $this->makePUTCurl("divisas/123", [
+ 'descripcion' => 'Divisa 123 Actualizada'
+ ]);
+ $expected = [
+ 'ok' => 'Registro actualizado correctamente.',
+ 'data' => [
+ 'coddivisa' => '123',
+ 'codiso' => null,
+ 'descripcion' => 'Divisa 123 Actualizada',
+ 'simbolo' => '?',
+ 'tasaconv' => 1,
+ 'tasaconvcompra' => 1
+ ]
+ ];
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testDeleteData()
+ {
+ $result = $this->makeDELETECurl("divisas/123");
+
+ $expected = [
+ 'ok' => 'Registro eliminado correctamente!',
+ 'data' => [
+ 'coddivisa' => '123',
+ 'codiso' => null,
+ 'descripcion' => 'Divisa 123 Actualizada',
+ 'simbolo' => '?',
+ 'tasaconv' => 1,
+ 'tasaconvcompra' => 1
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->stopAPIServer();
+ $this->logErrors();
+ }
+}
diff --git a/Test/API/ParametersTest.php b/Test/API/ParametersTest.php
new file mode 100644
index 0000000000..e0e50cb1eb
--- /dev/null
+++ b/Test/API/ParametersTest.php
@@ -0,0 +1,345 @@
+startAPIServer();
+ }
+
+ public function testListResources()
+ {
+
+ $result = $this->makeGETCurl();
+
+ $expected = [ 'resources' => $this->getResourcesList() ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ }
+
+ public function testFilterLike()
+ {
+ $result = $this->makeGETCurl('pais?filter[nombre_like]=Esp');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "ES",
+ "codpais" => "ESP",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 40.4637,
+ "longitude" => -3.7492,
+ "nick" => null,
+ "nombre" => "España",
+ "telephone_prefix" => "+34"
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testFilterData(){
+ $result = $this->makeGETCurl('pais?filter[codpais]=ESP');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "ES",
+ "codpais" => "ESP",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 40.4637,
+ "longitude" => -3.7492,
+ "nick" => null,
+ "nombre" => "España",
+ "telephone_prefix" => "+34"
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testFilterGreaterThan()
+ {
+ $result = $this->makeGETCurl('pais?filter[latitude_gt]=71.7069');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "SJ",
+ "codpais" => "SJM",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 77.5536,
+ "longitude" => 23.6703,
+ "nick" => null,
+ "nombre" => "Svalbard y Jan Mayen",
+ "telephone_prefix" => ""
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testFilterGreaterThanOrEqual(){
+ $result = $this->makeGETCurl('pais?filter[latitude_gte]=71.7069');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "GL",
+ "codpais" => "GRL",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 71.7069,
+ "longitude" => -42.6043,
+ "nick" => null,
+ "nombre" => "Groenlandia",
+ "telephone_prefix" => "+299"
+ ],
+ [
+ "alias" => null,
+ "codiso" => "SJ",
+ "codpais" => "SJM",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 77.5536,
+ "longitude" => 23.6703,
+ "nick" => null,
+ "nombre" => "Svalbard y Jan Mayen",
+ "telephone_prefix" => ""
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testFilterLessThan()
+ {
+ $result = $this->makeGETCurl('pais?filter[latitude_lt]=-54.4296');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "AQ",
+ "codpais" => "ATA",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => -90,
+ "longitude" => 0,
+ "nick" => null,
+ "nombre" => "Antártida",
+ "telephone_prefix" => ""
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testFilterLessThanOrEqual()
+ {
+ $result = $this->makeGETCurl('pais?filter[latitude_lte]=-54.4296');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "AQ",
+ "codpais" => "ATA",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => -90,
+ "longitude" => 0,
+ "nick" => null,
+ "nombre" => "Antártida",
+ "telephone_prefix" => ""
+ ],
+ [
+ "alias" => null,
+ "codiso" => "GS",
+ "codpais" => "SGS",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => -54.4296,
+ "longitude" => -36.5879,
+ "nick" => null,
+ "nombre" => "Islas Georgias del Sur y Sandwich del Sur",
+ "telephone_prefix" => ""
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testFilterDistinct(){
+ $result = $this->makeGETCurl('pais?filter[latitude_lte]=-54.4296&filter[latitude_neq]=-90');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "GS",
+ "codpais" => "SGS",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => -54.4296,
+ "longitude" => -36.5879,
+ "nick" => null,
+ "nombre" => "Islas Georgias del Sur y Sandwich del Sur",
+ "telephone_prefix" => ""
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testsort(){
+ $result = $this->makeGETCurl('pais?filter[latitude_lte]=-54.4296&sort[latitude]=DESC');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "GS",
+ "codpais" => "SGS",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => -54.4296,
+ "longitude" => -36.5879,
+ "nick" => null,
+ "nombre" => "Islas Georgias del Sur y Sandwich del Sur",
+ "telephone_prefix" => ""
+ ],
+ [
+ "alias" => null,
+ "codiso" => "AQ",
+ "codpais" => "ATA",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => -90,
+ "longitude" => 0,
+ "nick" => null,
+ "nombre" => "Antártida",
+ "telephone_prefix" => ""
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+ }
+
+ public function testPagination(){
+ $result = $this->makeGETCurl('pais?offset=0&limit=3');
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "AW",
+ "codpais" => "ABW",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 12.5211,
+ "longitude" => -69.9683,
+ "nick" => null,
+ "nombre" => "Aruba",
+ "telephone_prefix" => "+297"
+ ],
+ [
+ "alias" => null,
+ "codiso" => "AF",
+ "codpais" => "AFG",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 33.9391,
+ "longitude" => 67.71,
+ "nick" => null,
+ "nombre" => "Afganistán",
+ "telephone_prefix" => "+93"
+ ],
+ [
+ "alias" => null,
+ "codiso" => "AO",
+ "codpais" => "AGO",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 11.2027,
+ "longitude" => 17.8739,
+ "nick" => null,
+ "nombre" => "Angola",
+ "telephone_prefix" => "+244"
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ $result = $this->makeGETCurl('pais?offset=3&limit=3');
+
+ $expected = [
+ [
+ "alias" => null,
+ "codiso" => "AI",
+ "codpais" => "AIA",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 18.2206,
+ "longitude" => -63.0686,
+ "nick" => null,
+ "nombre" => "Anguila",
+ "telephone_prefix" => "+1 264"
+ ],
+ [
+ "alias" => null,
+ "codiso" => "AX",
+ "codpais" => "ALA",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 60.1785,
+ "longitude" => 19.9156,
+ "nick" => null,
+ "nombre" => "Islas Gland",
+ "telephone_prefix" => ""
+ ],
+ [
+ "alias" => null,
+ "codiso" => "AL",
+ "codpais" => "ALB",
+ "creation_date" => null,
+ "last_nick" => null,
+ "last_update" => null,
+ "latitude" => 41.1533,
+ "longitude" => 20.1683,
+ "nick" => null,
+ "nombre" => "Albania",
+ "telephone_prefix" => "+355"
+ ]
+ ];
+ }
+
+ protected function tearDown(): void
+ {
+ $this->stopAPIServer();
+ $this->logErrors();
+ }
+}
diff --git a/Test/API/SecurityTest.php b/Test/API/SecurityTest.php
new file mode 100644
index 0000000000..aa034f66ef
--- /dev/null
+++ b/Test/API/SecurityTest.php
@@ -0,0 +1,204 @@
+startAPIServer();
+
+ $agencia = new AgenciaTransporte();
+ $agencia = $agencia->get('TestTest');
+ if($agencia !== false) {
+ $this->assertTrue($agencia->delete(), 'agenciaTransporte-cant-delete');
+ }
+
+ Cache::deleteMulti(ApiController::IP_LIST);
+ }
+
+ // Test para comprobar el flujo de seguridad de la API facturascripts
+ public function testSecurityFlow()
+ {
+
+ $form = [
+ 'codtrans' => 'TestTest',
+ 'nombre' => 'La agencia inexistente',
+ ];
+
+
+ // paso 1: API Enabled?
+ Tools::settingsSet('default', 'enable_api', false);
+ Tools::settingsSave();
+
+ $result = [];
+
+ $expected = [
+ "status" => "error",
+ "message" => "API desactivada. Puede activarla desde el panel de control"
+ ];
+
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ Tools::settingsSet('default', 'enable_api', true);
+ Tools::settingsSave();
+
+
+ // paso 2: clave de API incorrecta
+ $expected = [
+ "status" => "error",
+ "message" => "Clave de API no válida"
+ ];
+
+ $this->setApiToken("invalid-token");
+
+ for ($attempt = 0; $attempt < ApiController::MAX_INCIDENT_COUNT; $attempt++) {
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+ $this->assertEquals($expected, $result, 'response-not-equal-' . $attempt);
+ }
+
+
+ // paso 3: IP baneada
+ $this->setApiToken('prueba');
+ $expected = [
+ "status" => "error",
+ "message" => "Por motivos de seguridad se ha bloqueado temporalmente el acceso desde su IP."
+ ];
+
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+ $this->assertEquals($expected, $result, 'response-not-equal-' . $attempt);
+
+ Cache::deleteMulti(ApiController::IP_LIST); // limpiar cache de ips bloqueadas
+ $this->stopAPIServer();
+ $this->startAPIServer();
+
+
+ // paso 4: Allowed resource
+ $this->token = 'invalid-token';
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+
+ $expected = [
+ "status" => "error",
+ "message" => "Clave de API no válida"
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+
+ //paso 5: Allowed resource
+ // clave api desactivada
+ $ApiKeyObj = new ApiKey();
+ $ApiKeyObj->clear();
+ $ApiKeyObj->description = 'Clave de pruebas';
+ $ApiKeyObj->nick = 'tester';
+ $ApiKeyObj->enabled = false;
+ $this->assertTrue($ApiKeyObj->save(), 'can-not-save-key');
+
+ $this->setApiToken($ApiKeyObj->apikey);
+
+ $expected = [
+ "status" => "error",
+ "message" => "Clave de API no válida"
+ ];
+
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ // clave api sin permisos (pero activada)
+ $ApiKeyObj->enabled = true;
+ $this->assertTrue($ApiKeyObj->save(), 'can-not-save-key');
+
+ $expected = [
+ "status" => "error",
+ "message" => "forbidden"
+ ];
+
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ // clave api con todos los permisos
+ $ApiKeyObj->fullaccess = true;
+ $this->assertTrue($ApiKeyObj->save(), 'can-not-save-key');
+
+ $expected = [
+ "ok" => "Registro actualizado correctamente.",
+ "data" => [
+ "activo" => true,
+ "codtrans" => "TestTest",
+ "nombre" => "La agencia inexistente",
+ "telefono" => null,
+ "web" => null
+ ]
+ ];
+
+ $result = $this->makePOSTCurl("agenciatransportes", $form);
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ // clave api con permisos limitados
+ $ApiKeyObj->fullaccess = false;
+ $this->assertTrue($ApiKeyObj->save(), 'can-not-save-key');
+ $this->assertTrue(ApiAccess::addResourcesToApiKey($ApiKeyObj->id, ['agenciatransportes'], true), 'can-not-add-resource');
+
+ $form = [
+ 'nombre' => 'La agencia intangible',
+ 'activo' => false
+ ];
+
+ $result = $this->makePUTCurl("agenciatransportes/TestTest", $form);
+
+ $expected = [
+ "ok" => "Registro actualizado correctamente.",
+ "data" => [
+ "activo" => false,
+ "codtrans" => "TestTest",
+ "nombre" => "La agencia intangible",
+ "telefono" => null,
+ "web" => null
+ ]
+ ];
+
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ $expected = [
+ "status" => "error",
+ "message" => "forbidden"
+ ];
+
+ $result = $this->makeGETCurl("divisas");
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ $this->assertTrue(ApiAccess::addResourcesToApiKey($ApiKeyObj->id, ['agenciatransportes'], false), 'can-not-add-resource');
+ $result = $this->makeGETCurl("agenciatransportes");
+ $this->assertEquals($expected, $result, 'response-not-equal');
+
+ $ApiKeyObj->delete();
+ Cache::deleteMulti(ApiController::IP_LIST);
+ }
+
+ protected function tearDown(): void
+ {
+ $agencia = new AgenciaTransporte();
+ $agencia->get('TestTest');
+ if($agencia !== false) {
+ $this->assertTrue($agencia->delete(), 'agenciaTransporte-cant-delete');
+ }
+
+ $this->stopAPIServer();
+ $this->logErrors();
+ }
+}
diff --git a/Test/Traits/ApiTrait.php b/Test/Traits/ApiTrait.php
new file mode 100644
index 0000000000..6a4994b27c
--- /dev/null
+++ b/Test/Traits/ApiTrait.php
@@ -0,0 +1,264 @@
+
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+namespace FacturaScripts\Test\Traits;
+
+use FacturaScripts\Core\Tools;
+use FacturaScripts\Dinamic\Model\ApiKey;
+
+trait ApiTrait
+{
+ private string $host = '127.0.0.2';
+ private string $port = '8000';
+ private string $document_root = '/../../';
+ private string $router = 'index.php';
+
+ private string $url;
+ private string $token; // generado en start server y removido en stop server
+ private string $pid;
+ private string $command;
+
+ private bool $defaultApiEnabled;
+
+ private ApiKey $ApiKeyObj;
+
+ protected function startAPIServer($enableAPI = true): void
+ {
+ $document_root = __DIR__ . $this->document_root;
+ $router = $document_root . $this->router;
+
+ $this->defaultApiEnabled = Tools::settings('default', 'enable_api', false);
+ Tools::settingsSet('default', 'enable_api', $enableAPI);
+ Tools::settingsSave();
+
+ $ApiKeyObj = new ApiKey();
+ // $ApiKeyObj->id = $IdKey.'Test';
+ $ApiKeyObj->clear();
+ $ApiKeyObj->description = 'Clave de pruebas';
+ $ApiKeyObj->nick = 'tester';
+ $ApiKeyObj->enabled = true;
+ $ApiKeyObj->fullaccess = true;
+
+ $ApiKeyObj->save();
+ $this->token = $ApiKeyObj->apikey;
+ $this->ApiKeyObj = $ApiKeyObj;
+
+ $this->url = "http://{$this->host}:{$this->port}/api/3/";
+ $this->command = "php -S {$this->host}:{$this->port} -t {$document_root} {$router} > /dev/null 2>&1 & echo $!";
+ $this->pid = shell_exec($this->command);
+ sleep(1);
+ }
+
+ protected function stopAPIServer(): void
+ {
+ Tools::settingsSet('default', 'enable_api', $this->defaultApiEnabled);
+ Tools::settingsSave();
+ shell_exec("kill $this->pid");
+ }
+
+ protected function setApiUrl(string $url): void
+ {
+ $this->url = $url;
+ }
+
+ protected function setApiToken(string $token): void
+ {
+ $this->token = $token;
+ }
+
+ protected function makeGETCurl(string $params = ''): array
+ {
+ $ch = curl_init($this->url . $params);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ "Token: " . $this->token
+ ]);
+ $respuesta = curl_exec($ch);
+ curl_close($ch);
+
+ $data = json_decode($respuesta, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return $data;
+ } else {
+ throw new \Exception('Error al decodificar la respuesta JSON: ' . json_last_error_msg());
+ }
+ }
+
+ protected function makePOSTCurl(string $params = '', array $data = []): array
+ {
+ $ch = curl_init($this->url . $params);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); // <-- Aquí el cambio
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ "Token: " . $this->token,
+ "Content-Type: application/x-www-form-urlencoded" // <-- Aquí el cambio
+ ]);
+
+ $respuesta = curl_exec($ch);
+ curl_close($ch);
+ $data = json_decode($respuesta, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return $data;
+ } else {
+ echo $respuesta;
+ throw new \Exception('Error al decodificar la respuesta JSON: ' . json_last_error_msg());
+ }
+ }
+
+ protected function makePUTCurl(string $params = '', array $data = []): array
+ {
+ $ch = curl_init($this->url . $params);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); // <-- Cambiado aquí
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ "Token: " . $this->token,
+ "Content-Type: application/x-www-form-urlencoded" // <-- Cambiado aquí
+ ]);
+
+ $respuesta = curl_exec($ch);
+ curl_close($ch);
+
+ $data = json_decode($respuesta, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return $data;
+ } else {
+ throw new \Exception('Error al decodificar la respuesta JSON: ' . json_last_error_msg());
+ }
+ }
+
+ protected function makeDELETECurl(string $params = '', array $data = []): array
+ {
+ $ch = curl_init($this->url . $params);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); // <-- Cambiado aquí
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ "Token: " . $this->token,
+ "Content-Type: application/x-www-form-urlencoded" // <-- Cambiado aquí
+ ]);
+
+ $respuesta = curl_exec($ch);
+ curl_close($ch);
+
+ $data = json_decode($respuesta, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return $data;
+ } else {
+ throw new \Exception('Error al decodificar la respuesta JSON: ' . json_last_error_msg());
+ }
+ }
+
+
+ protected function getResourcesList(): array
+ {
+ return [
+ "agenciatransportes",
+ "agentes",
+ "albaranclientes",
+ "albaranproveedores",
+ "almacenes",
+ "apiaccess",
+ "apikeyes",
+ "asientos",
+ "atributos",
+ "atributovalores",
+ "attachedfilerelations",
+ "attachedfiles",
+ "ciudades",
+ "clientes",
+ "codemodeles",
+ "codigopostales",
+ "conceptopartidas",
+ "contactos",
+ "crearFacturaCliente",
+ "crearFacturaRectificativaCliente",
+ "cronjobes",
+ "cuentabancoclientes",
+ "cuentabancoproveedores",
+ "cuentabancos",
+ "cuentaespeciales",
+ "cuentas",
+ "diarios",
+ "divisas",
+ "doctransformations",
+ "ejercicios",
+ "emailnotifications",
+ "emailsentes",
+ "empresas",
+ "estadodocumentos",
+ "exportarFacturaCliente",
+ "fabricantes",
+ "facturaclientes",
+ "facturaproveedores",
+ "familias",
+ "formapagos",
+ "formatodocumentos",
+ "grupoclientes",
+ "identificadorfiscales",
+ "impuestos",
+ "impuestozonas",
+ "lineaalbaranclientes",
+ "lineaalbaranproveedores",
+ "lineafacturaclientes",
+ "lineafacturaproveedores",
+ "lineapedidoclientes",
+ "lineapedidoproveedores",
+ "lineapresupuestoclientes",
+ "lineapresupuestoproveedores",
+ "logmessages",
+ "pagefilteres",
+ "pageoptions",
+ "pages",
+ "pagoclientes",
+ "pagoproveedores",
+ "pais",
+ "partidas",
+ "pedidoclientes",
+ "pedidoproveedores",
+ "presupuestoclientes",
+ "presupuestoproveedores",
+ "productoimagenes",
+ "productoproveedores",
+ "productos",
+ "proveedores",
+ "provincias",
+ "puntointeresciudades",
+ "reciboclientes",
+ "reciboproveedores",
+ "regularizacionimpuestos",
+ "retenciones",
+ "roleaccess",
+ "roles",
+ "roleusers",
+ "secuenciadocumentos",
+ "series",
+ "settings",
+ "stocks",
+ "subcuentas",
+ "tarifas",
+ "totalmodeles",
+ "uploadFiles",
+ "users",
+ "variantes",
+ "workeventes"
+ ];
+ }
+}
\ No newline at end of file
diff --git a/phpunit-api.xml b/phpunit-api.xml
new file mode 100644
index 0000000000..db79a44cd0
--- /dev/null
+++ b/phpunit-api.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Test/API/
+
+
+
+
\ No newline at end of file
diff --git a/seed.sql b/seed.sql
new file mode 100644
index 0000000000..268ceabed0
--- /dev/null
+++ b/seed.sql
@@ -0,0 +1,32 @@
+create database facturascripts;
+use facturascripts;
+create table proveedores (
+ acreedor int not null,
+ cifnif varchar(20) not null,
+ codcliente varchar(20),
+ codimpuestoportes varchar(20),
+ codpago varchar(20),
+ codproveedor varchar(20) primary key,
+ codretencion varchar(20),
+ codserie varchar(20),
+ codsubcuenta varchar(20),
+ debaja int not null default 0,
+ email varchar(100),
+ fax varchar(50),
+ fechaalta date not null,
+ fechabaja date,
+ idcontacto int not null default 1,
+ langcode varchar(10) not null default 'es_ES',
+ nombre varchar(100) not null,
+ observaciones text,
+ personafisica int not null default 0,
+ razonsocial varchar(100) not null,
+ regimeniva varchar(50) not null default 'General',
+ telefono1 varchar(50),
+ telefono2 varchar(50),
+ tipoidfiscal varchar(10) not null default 'NIF',
+ web varchar(100)
+);
+
+INSERT INTO facturascripts.proveedores (acreedor,cifnif,codcliente,codimpuestoportes,codpago,codproveedor,codretencion,codserie,codsubcuenta,debaja,email,fax,fechaalta,fechabaja,idcontacto,langcode,nombre,observaciones,personafisica,razonsocial,regimeniva,telefono1,telefono2,tipoidfiscal,web) VALUES
+ (0,'',NULL,'IVA21',NULL,'1',NULL,NULL,'',0,'','','2025-06-03',NULL,1,'es_ES','prueba','',1,'prueba','General','','','NIF','')
\ No newline at end of file