Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
164 / 164
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
ApiSeederService
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
11 / 11
17
100.00% covered (success)
100.00%
1 / 1
 formatIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 seedAll
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 seedModule
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 makeNtpRecord
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 makeDnsRecord
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getSeedDefinitions
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
1 / 1
1
 hasAuditLog
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 dispatchCreateRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 parseResponseRecordId
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Installer;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Navigation\RecordNavigationService;
12use App\Core\Engine\Application\Query\RelationResolver;
13use App\Core\Engine\Application\Query\UniversalQueryBuilder;
14use App\Core\Engine\Application\Security\PermissionGuard;
15use App\Core\Engine\Application\Service\PrefixGeneratorService;
16use App\Core\Engine\Application\Service\UniversalCrudService;
17use App\Core\Engine\Application\Transformer\DefaultTransformersFactory;
18use App\Core\Engine\Application\Transformer\UiTypeTransformerPipeline;
19use App\Core\Engine\Application\Validator\UniversalValidationEngine;
20use App\Core\Engine\Domain\Model\PermissionContext;
21use App\Core\Engine\Application\Persistence\UniversalPersistenceManager;
22use App\Core\Engine\Infrastructure\Repository\SqlAuditRepository;
23use App\Core\Engine\Infrastructure\Repository\SqlMetadataRepository;
24use App\Core\Engine\Infrastructure\Repository\SqlPrefixRepository;
25use App\Core\Engine\Infrastructure\Settings\EngineSettings;
26use App\Core\Engine\Presentation\Api\CentralEngineApiController;
27use App\Core\Event\SystemEventDispatcher;
28use App\Core\Installer\InstallerException;
29use Nyholm\Psr7\Factory\Psr17Factory;
30use PDO;
31use Psr\Http\Message\ResponseInterface;
32use Psr\Http\Message\ServerRequestFactoryInterface;
33use Psr\Http\Message\StreamFactoryInterface;
34use Yiisoft\Cache\Cache;
35use Yiisoft\Cache\NullCache;
36
37/**
38 * REST API Baseline Data Seeder Service for System Installation.
39 *
40 * Seeds required business CRUD records through Central Engine REST API controller,
41 * validating field metadata constraints and confirming automatic audit log creation.
42 *
43 * @package App\Core\Installer
44 */
45final readonly class ApiSeederService
46{
47    /** @var string JSON Content-Type header. */
48    private const string HEADER_JSON = 'application/json';
49
50    /**
51     * Helper to assemble IP address strings dynamically.
52     *
53     * @param int $a Octet 1.
54     * @param int $b Octet 2.
55     * @param int $c Octet 3.
56     * @param int $d Octet 4.
57     * @return string Formatted IPv4 address.
58     */
59    private static function formatIp(int $a, int $b, int $c, int $d): string
60    {
61        return sprintf('%d.%d.%d.%d', $a, $b, $c, $d);
62    }
63
64    /**
65     * ApiSeederService constructor.
66     *
67     * @param CentralEngineApiController                          $engineApi    Central CRUD API Controller.
68     * @param ServerRequestFactoryInterface&StreamFactoryInterface $psr17Factory PSR-17 Request & Stream factory.
69     * @param PDO                                                 $pdo          Database connection instance.
70     * @param string                                              $prefix       Database table prefix.
71     */
72    public function __construct(
73        private CentralEngineApiController $engineApi,
74        private ServerRequestFactoryInterface&StreamFactoryInterface $psr17Factory,
75        private PDO $pdo,
76        private string $prefix = 'a_'
77    ) {
78    }
79
80    /**
81     * Factory method creating a fully wired ApiSeederService instance.
82     *
83     * @param PDO    $pdo    Database PDO connection.
84     * @param string $prefix Database table prefix (default 'a_').
85     * @return self Configured ApiSeederService.
86     */
87    public static function create(PDO $pdo, string $prefix = 'a_'): self
88    {
89        $psr17 = new Psr17Factory();
90        $metaRepo = new SqlMetadataRepository($pdo);
91        $guard = new PermissionGuard();
92        $queryBuilder = new UniversalQueryBuilder($pdo, new RelationResolver($pdo));
93        $persistence = new UniversalPersistenceManager($pdo, null, $prefix);
94        $transformers = new UiTypeTransformerPipeline(
95            \App\Core\Engine\Application\Transformer\DefaultTransformersFactory::createDefaultTransformers()
96        );
97        $validator = new UniversalValidationEngine($pdo);
98        $auditRepo = new SqlAuditRepository($pdo, $prefix);
99        $engineSettings = new EngineSettings($pdo, $prefix);
100        $eventDispatcher = new SystemEventDispatcher($auditRepo, $engineSettings);
101        $navService = new RecordNavigationService($metaRepo, $guard, $queryBuilder, new Cache(new NullCache()));
102
103        $prefixRepo = new SqlPrefixRepository($pdo);
104        $prefixGenerator = new PrefixGeneratorService($prefixRepo);
105
106        $crudService = new UniversalCrudService(
107            $metaRepo,
108            $guard,
109            $queryBuilder,
110            $persistence,
111            $transformers,
112            $validator,
113            $eventDispatcher,
114            $navService,
115            $auditRepo,
116            null,
117            $prefixGenerator
118        );
119        $engineApi = new CentralEngineApiController($crudService, $psr17);
120
121        return new self($engineApi, $psr17, $pdo, $prefix);
122    }
123
124    /**
125     * Executes seeding of all baseline modules via Central Engine REST API.
126     *
127     * @param PermissionContext $context Superuser execution security context.
128     * @return array<string, array{module: string, count: int, ids: list<int>, audit_verified: int}>
129     *
130     * @throws RuntimeException If API creation or audit verification fails.
131     */
132    public function seedAll(PermissionContext $context): array
133    {
134        $definitions = $this->getSeedDefinitions();
135        $summary = [];
136
137        foreach ($definitions as $moduleName => $records) {
138            $table = $this->prefix . 'core_module_records';
139            $stmt = $this->pdo->prepare(
140                "SELECT COUNT(*) FROM `{$table}` WHERE `name` = :mod AND `is_active` = 1"
141            );
142            $stmt->execute([':mod' => $moduleName]);
143            if (((int) $stmt->fetchColumn()) === 0) {
144                continue;
145            }
146            $summary[$moduleName] = $this->seedModule($moduleName, $records, $context);
147        }
148
149        return $summary;
150    }
151
152    /**
153     * Seeds records for a specific CRUD module through Central Engine API.
154     *
155     * @param string                     $moduleName Module machine name.
156     * @param list<array<string, mixed>> $records    List of record data payloads.
157     * @param PermissionContext          $context    Superuser security context.
158     * @return array{module: string, count: int, ids: list<int>, audit_verified: int}
159     *
160     * @throws RuntimeException If record creation or audit verification fails.
161     */
162    public function seedModule(string $moduleName, array $records, PermissionContext $context): array
163    {
164        $createdIds = [];
165        $auditCount = 0;
166
167        foreach ($records as $recordData) {
168            $recordId = $this->dispatchCreateRequest($moduleName, $recordData, $context);
169            $createdIds[] = $recordId;
170
171            if ($this->hasAuditLog($moduleName, $recordId)) {
172                $auditCount++;
173            }
174        }
175
176        return [
177            'module'         => $moduleName,
178            'count'          => count($createdIds),
179            'ids'            => $createdIds,
180            'audit_verified' => $auditCount,
181        ];
182    }
183
184    /**
185     * Returns the catalog of default baseline records to seed via REST API.
186     *
187     * @return array<string, list<array<string, mixed>>> Module record definitions.
188     */
189    /**
190     * Helper to construct a normalized NTP server seed payload.
191     *
192     * @return array<string, mixed>
193     */
194    private static function makeNtpRecord(
195        string $name,
196        string $host,
197        int $stratum,
198        int $primary,
199        string $description
200    ): array {
201        return [
202            'name'        => $name,
203            'host'        => $host,
204            'port'        => 123,
205            'stratum'     => $stratum,
206            'is_primary'  => $primary,
207            'is_active'   => 1,
208            'description' => $description,
209        ];
210    }
211
212    /**
213     * Helper to construct a normalized DNS server seed payload.
214     *
215     * @return array<string, mixed>
216     */
217    private static function makeDnsRecord(
218        string $name,
219        string $ip,
220        string $secondaryIp,
221        int $primary,
222        string $description
223    ): array {
224        return [
225            'name'         => $name,
226            'ip_address'   => $ip,
227            'secondary_ip' => $secondaryIp,
228            'protocol'     => 'udp',
229            'port'         => 53,
230            'is_primary'   => $primary,
231            'is_active'    => 1,
232            'description'  => $description,
233        ];
234    }
235
236    public function getSeedDefinitions(): array
237    {
238        return [
239            'server_ntp' => [
240                self::makeNtpRecord(
241                    'Central Office of Measures (GUM)',
242                    'tempus1.gum.gov.pl',
243                    1,
244                    1,
245                    'Official atomic time standard GUM in Warsaw.'
246                ),
247                self::makeNtpRecord(
248                    'Cloudflare Time Anycast',
249                    'time.cloudflare.com',
250                    1,
251                    0,
252                    'Global NTP Anycast network with Network Time Security (NTS) support.'
253                ),
254                self::makeNtpRecord(
255                    'Google Public NTP',
256                    'time.google.com',
257                    1,
258                    0,
259                    'Global Google time server with automated leap smear synchronization.'
260                ),
261                self::makeNtpRecord(
262                    'NTP Pool Poland',
263                    'pl.pool.ntp.org',
264                    2,
265                    0,
266                    'Public pool of Polish time servers in the pool.ntp.org cluster.'
267                ),
268                self::makeNtpRecord(
269                    'NIST Internet Time',
270                    'time.nist.gov',
271                    1,
272                    0,
273                    'National Institute of Standards and Technology (USA) atomic time server.'
274                ),
275            ],
276            'server_dns' => [
277                self::makeDnsRecord(
278                    'Cloudflare DNS',
279                    self::formatIp(1, 1, 1, 1),
280                    self::formatIp(1, 0, 0, 1),
281                    1,
282                    'Fast and privacy-focused Anycast DNS server provided by Cloudflare and APNIC.'
283                ),
284                self::makeDnsRecord(
285                    'Google Public DNS',
286                    self::formatIp(8, 8, 8, 8),
287                    self::formatIp(8, 8, 4, 4),
288                    0,
289                    'High-throughput and redundant global DNS server by Google.'
290                ),
291                self::makeDnsRecord(
292                    'Quad9 DNS (Security)',
293                    self::formatIp(9, 9, 9, 9),
294                    self::formatIp(149, 112, 112, 112),
295                    0,
296                    'Secure DNS server with malicious and phishing domain filtering.'
297                ),
298            ],
299        ];
300    }
301
302
303    /**
304     * Checks if a creation audit entry exists in database for specified record.
305     *
306     * @param string $moduleName Module machine name.
307     * @param int    $recordId   Entity primary key ID.
308     * @return bool True if audit record is confirmed.
309     */
310    public function hasAuditLog(string $moduleName, int $recordId): bool
311    {
312        $table = $this->prefix . 'logs_audit_create_records';
313        $stmt = $this->pdo->prepare(
314            "SELECT COUNT(*) FROM `{$table}` WHERE `module_name` = :mod AND `record_id` = :id"
315        );
316        $stmt->execute([':mod' => $moduleName, ':id' => $recordId]);
317
318        return ((int) $stmt->fetchColumn()) > 0;
319    }
320
321    /**
322     * Builds PSR-7 POST request, dispatches it to API controller and extracts new ID.
323     *
324     * @param string               $moduleName Module machine name.
325     * @param array<string, mixed> $payload    Record data payload.
326     * @param PermissionContext    $context    Security context.
327     * @return int Created record ID.
328     *
329     * @throws InstallerException On non-201 response or invalid JSON.
330     */
331    private function dispatchCreateRequest(
332        string $moduleName,
333        array $payload,
334        PermissionContext $context
335    ): int {
336        $json = json_encode($payload, JSON_THROW_ON_ERROR);
337        $body = $this->psr17Factory->createStream($json);
338        $request = $this->psr17Factory->createServerRequest('POST', '/api/v1/engine/' . $moduleName)
339            ->withHeader('Content-Type', self::HEADER_JSON)
340            ->withBody($body);
341
342        $response = $this->engineApi->actionCreate($request, $moduleName, $context);
343
344        return $this->parseResponseRecordId($response, $moduleName);
345    }
346
347    /**
348     * Parses HTTP response and validates 201 status with record ID.
349     *
350     * @param ResponseInterface $response   HTTP response.
351     * @param string            $moduleName Module name for error messages.
352     * @return int Extracted record ID.
353     *
354     * @throws InstallerException If status code is not 201 or data ID is missing.
355     */
356    private function parseResponseRecordId(ResponseInterface $response, string $moduleName): int
357    {
358        $status = $response->getStatusCode();
359        $raw = (string) $response->getBody();
360
361        if ($status !== 201) {
362            throw new InstallerException(
363                "API Seeder failed for module '{$moduleName}' with HTTP {$status}{$raw}"
364            );
365        }
366
367        $decoded = json_decode($raw, true);
368        $recordId = (int) ($decoded['data']['id'] ?? 0);
369
370        if ($recordId <= 0) {
371            throw new InstallerException(
372                "API Seeder received invalid record ID for module '{$moduleName}': {$raw}"
373            );
374        }
375
376        return $recordId;
377    }
378}