Symfony sigurnosna nadzorna ploča: Zakažite provjere web-stranice
Sigurnosna nadzorna ploča stječe povjerenje samo kada jasno odgovara na dva pitanja: što sada nije u redu i kada su dokazi posljednji put osvježeni? Jednokratno skeniranje to ne može učiniti. Produkcijski sustavi trebaju ponovljivu procjenu, sačuvane rezultate, razumljiva stanja neuspjeha i strogo razdvajanje podataka korisnika.
Ovaj vodič gradi takav sustav s PHP-om 8.3, Symfonyjem 7.4, PostgreSQL-om, Symfony HttpClientom i Mihajlo Website Security Analyzerom. Gotova aplikacija pohranjuje autorizirane korisničke web-stranice, ponovno ih procjenjuje prema rasporedu, čuva povijest procjena i prikazuje najnovije sigurnosno stanje web-stranice putem autentificirane nadzorne ploče.
Analizator provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog stanja preglednika. Ne smije se opisivati kao penetracijsko testiranje, certifikacija ranjivosti ili dokaz da je web-stranica sigurna.
Dobijte pristup i kopirajte servisni token
Dovršite postavljanje pristupa prije izrade Symfony klijenta:
- Izradite račun za AI alate ili se prijavite ako ga već imate.
- Otvorite stranicu usluge Website Security Analyzer, pomaknite se do planova, odaberite Free za početni test ili veći plan za produkcijsku upotrebu te dovršite aktivaciju.
- Otvorite interaktivnu API dokumentaciju. U ploči Service token pritisnite Copy kako biste kopirali token ograničen na uslugu.
- Stavite token u
.env.local. Nikada ga nemojte potvrditi u repozitorij. Ponovno generiranje tokena opoziva prethodni aktivni token, stoga odmah nakon rotacije ažurirajte postavljenu tajnu.
MIHAJLO_API_TOKEN=YOUR_SERVICE_TOKEN
Provjerite token i ugovor zahtjeva izravno prije povezivanja sa Symfonyjem:
curl --request POST \
--url https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data "{\"url\":\"https://example.com\"}"
Uspješan JSON odgovor potvrđuje da je plan aktivan, token valjan i krajnja točka dostupna s računala na kojem će se projekt pokretati.
Najprije razumite granicu API-ja
Službeni ugovor dokumentiran je u API dokumentaciji za Website Security Analyzer. Integracija koristi točno:
POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Autentikacija može koristiti Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Ova implementacija bira Bearer oblik jer drži vjerodajnice izvan URL-ova i zapisnika pristupa. JSON zahtjev sadrži jedno polje, url. Odgovor se mapira u rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke.
Udaljeni odgovor je nepouzdan ulaz. Čak i kada su polja dokumentirana, aplikacija provjerava njihove tipove prije nego što bilo što dospije u pohranu ili Twig.
Arhitektura i kompromisi
Preglednik nikada ne poziva analizator. Zakazana Symfony naredba obavlja spori vanjski posao, dok kontroler čita lokalnu snimku stanja. Korisnici zato dobivaju predvidivu latenciju nadzorne ploče čak i kada je uzvodni API spor ili privremeno nedostupan.
- Klijent analizatora: upravlja autentikacijom, vremenskim ograničenjima, provjerom odgovora i ograničenim ponovnim pokušajima.
- Rezultat domene: sprječava da proizvoljan uzvodni JSON procuri kroz aplikaciju.
- Repozitorij: preuzima dospjele web-stranice zakupom baze podataka te pohranjuje snimke stanja i povijest.
- Konzolna naredba: ponovno procjenjuje web-stranice izvan obrade zahtjeva.
- Kontroler nadzorne ploče: provodi vlasništvo korisnika prije prikaza rezultata.
Messenger bi bio razuman pri znatno većem protoku, ali ovdje nije potreban. Zakup baze podataka i mala zakazana serija pružaju oporavak nakon rušenja i sigurno izvršavanje na više instanci bez uvođenja dodatnog transporta.
Izradite Symfony projekt
Symfony 7.4 podržava PHP 8.3. Doctrine DBAL 4 pruža eksplicitnu SQL i kontrolu transakcija bez potrebe za ORM entitetima za ovaj mali model pohrane.
composer create-project symfony/skeleton:"7.4.*" security-dashboard
cd security-dashboard
composer require symfony/http-client:^7.4 symfony/console:^7.4 \
symfony/twig-bundle:^7.4 symfony/security-bundle:^7.4 \
doctrine/doctrine-bundle:^2.13 doctrine/dbal:^4.0
composer require --dev symfony/test-pack
Relevantna struktura projekta namjerno je kompaktna:
src/
Command/ReassessWebsitesCommand.php
Controller/SecurityDashboardController.php
Security/AnalyzerClient.php
Security/AnalyzerException.php
Security/AssessmentResult.php
Security/AssessmentRepository.php
templates/security/dashboard.html.twig
tests/Security/AnalyzerClientTest.php
migrations/Version20260811000000.php
config/services.yaml
Konfigurirajte tajne i ubrizgavanje ovisnosti
Potvrdite krajnju točku kao uobičajenu konfiguraciju, ali token ubrizgajte putem okruženja. Stavite rezervirano mjesto u .env; za stvarnu vrijednost koristite .env.local, tajnu za implementaciju ili upravitelj tajnama.
MIHAJLO_ANALYZER_ENDPOINT=https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
MIHAJLO_API_TOKEN=YOUR_SERVICE_TOKEN
DATABASE_URL=postgresql://app:[email protected]:5432/security_dashboard
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Security\AnalyzerClient:
arguments:
$endpoint: '%env(string:MIHAJLO_ANALYZER_ENDPOINT)%'
$token: '%env(string:MIHAJLO_API_TOKEN)%'
Nikada nemojte zapisivati token, autorizacijsko zaglavlje ni potpuni uzvodni odgovor. URL-ovi također mogu sadržavati osjetljive putanje ili nizove upita, stoga bi operativni zapisnici obično trebali navoditi samo ID web-stranice i naziv hosta.
Izradite obrambeni mapper domene
Mapper zahtijeva sva četiri područja rezultata, izbjegavajući pritom pretpostavke o pojedinačnim objektima nalaza. To čuva kompatibilnost s budućim verzijama unutar svake skupine ozbiljnosti bez prihvaćanja strukturno nepovezanog odgovora.
<?php
// src/Security/AssessmentResult.php
namespace App\Security;
final readonly class AssessmentResult
{
public function __construct(
public int|float $score,
public array $findingsBySeverity,
public array $tlsDetails,
public array $recommendations,
) {}
public static function fromPayload(array $data): self
{
if (!isset($data['score']) || !is_int($data['score']) && !is_float($data['score'])) {
throw new \UnexpectedValueException('Analyzer score is missing or invalid.');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new \UnexpectedValueException("Analyzer field {$field} is invalid.");
}
}
foreach ($data['findings'] as $severity => $findings) {
if (!is_string($severity) || !is_array($findings)) {
throw new \UnexpectedValueException('Findings are not grouped by severity.');
}
}
return new self(
$data['score'],
$data['findings'],
$data['tls'],
array_values($data['recommendations']),
);
}
}
Twig prema zadanim postavkama escapea prikazane nizove. Zadržite tu zaštitu uključenom: izlaz analizatora nikada se ne smije označiti kao siguran ni prikazati kao sirovi HTML.
Implementirajte otporni HTTP klijent
Vanjski pozivi dobivaju ograničena vremenska ograničenja povezivanja i ukupnog odgovora. Klijent ponovno pokušava kod neuspjeha transporta, HTTP 429 i pogrešaka poslužitelja najviše dvaput nakon prvog pokušaja. Autentikacija, provjera valjanosti i druge pogreške klijenta odmah ne uspijevaju jer ih drugi identičan zahtjev neće popraviti.
<?php
// src/Security/AnalyzerException.php
namespace App\Security;
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $category,
public readonly bool $retryable,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/Security/AnalyzerClient.php
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class AnalyzerClient
{
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $endpoint,
private string $token,
) {}
public function analyze(string $url): AssessmentResult
{
$host = parse_url($url, PHP_URL_HOST) ?: 'invalid-host';
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', $this->endpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new \UnexpectedValueException('Expected a JSON object.');
}
return AssessmentResult::fromPayload($payload);
} catch (\JsonException|\UnexpectedValueException $e) {
throw new AnalyzerException(
'response_contract',
false,
'The analyzer returned an invalid response.',
$e
);
}
}
$retryable = $status === 429 || $status >= 500;
$category = match (true) {
$status === 401 || $status === 403 => 'authentication',
$status === 400 || $status === 422 => 'invalid_target',
$status === 429 => 'rate_limited',
$status >= 500 => 'upstream',
default => 'http_error',
};
if (!$retryable || $attempt === 3) {
throw new AnalyzerException(
$category,
$retryable,
"Analyzer request failed with HTTP {$status}."
);
}
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new AnalyzerException(
'transport',
true,
'Analyzer transport failed.',
$e
);
}
}
$this->logger->warning('Website analysis will be retried.', [
'host' => $host,
'attempt' => $attempt,
]);
sleep(2 ** ($attempt - 1));
}
throw new \LogicException('Retry loop terminated unexpectedly.');
}
}
Produkcijska varijanta može poštovati numeričko zaglavlje Retry-After, ali bi trebala ograničiti odgodu na konfigurirani maksimum. Nikada nemojte dopustiti da uzvodno zaglavlje neograničeno obustavi radnik.
Pohranite snimke stanja, povijest i stanje raspoređivanja
Sljedeća PostgreSQL migracija odvaja trenutačnu snimku stanja nadzorne ploče od nepromjenjive povijesti procjena. locked_until je obnovljivi zahtjev, a ne trajno zaključavanje; srušeni proces ponovno postaje prihvatljiv nakon isteka zakupa.
CREATE TABLE monitored_site (
id BIGSERIAL PRIMARY KEY,
client_id VARCHAR(180) NOT NULL,
url TEXT NOT NULL,
interval_minutes INTEGER NOT NULL CHECK (interval_minutes >= 15),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
next_assessment_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_until TIMESTAMPTZ NULL,
last_status VARCHAR(32) NULL,
last_error_code VARCHAR(64) NULL,
latest_score DOUBLE PRECISION NULL,
latest_findings JSONB NULL,
latest_tls JSONB NULL,
latest_recommendations JSONB NULL,
latest_assessed_at TIMESTAMPTZ NULL,
UNIQUE (client_id, url)
);
CREATE INDEX monitored_site_due_idx
ON monitored_site (next_assessment_at)
WHERE enabled = TRUE;
CREATE TABLE security_assessment (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL REFERENCES monitored_site(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL,
score DOUBLE PRECISION NULL,
findings JSONB NULL,
tls JSONB NULL,
recommendations JSONB NULL,
error_code VARCHAR(64) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Repozitorij preuzima jedan zapis odjednom. To održava zakup valjanim čak i kada raniji API zahtjev potroši cijeli proračun vremenskog ograničenja.
<?php
// Odabrane metode iz src/Security/AssessmentRepository.php
namespace App\Security;
use Doctrine\DBAL\Connection;
final readonly class AssessmentRepository
{
public function __construct(private Connection $db) {}
public function claimOne(): ?array
{
$row = $this->db->fetchAssociative(<<<'SQL'
WITH candidate AS (
SELECT id FROM monitored_site
WHERE enabled = TRUE
AND next_assessment_at <= CURRENT_TIMESTAMP
AND (locked_until IS NULL OR locked_until < CURRENT_TIMESTAMP)
ORDER BY next_assessment_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE monitored_site AS site
SET locked_until = CURRENT_TIMESTAMP + INTERVAL '2 minutes'
FROM candidate
WHERE site.id = candidate.id
RETURNING site.*
SQL);
return $row ?: null;
}
public function saveSuccess(array $site, AssessmentResult $result): void
{
$values = [
'id' => $site['id'],
'score' => $result->score,
'findings' => json_encode($result->findingsBySeverity, JSON_THROW_ON_ERROR),
'tls' => json_encode($result->tlsDetails, JSON_THROW_ON_ERROR),
'recommendations' => json_encode($result->recommendations, JSON_THROW_ON_ERROR),
];
$this->db->transactional(function (Connection $db) use ($values): void {
$db->executeStatement(<<<'SQL'
INSERT INTO security_assessment
(site_id, status, score, findings, tls, recommendations)
VALUES (:id, 'success', :score, :findings::jsonb, :tls::jsonb, :recommendations::jsonb)
SQL, $values);
$db->executeStatement(<<<'SQL'
UPDATE monitored_site
SET last_status = 'success', last_error_code = NULL,
latest_score = :score, latest_findings = :findings::jsonb,
latest_tls = :tls::jsonb, latest_recommendations = :recommendations::jsonb,
latest_assessed_at = CURRENT_TIMESTAMP, locked_until = NULL,
next_assessment_at =
CURRENT_TIMESTAMP + interval_minutes * INTERVAL '1 minute'
WHERE id = :id
SQL, $values);
});
}
public function saveFailure(array $site, AnalyzerException $e): void
{
$delay = $e->retryable ? 15 : (int) $site['interval_minutes'];
$this->db->executeStatement(<<<'SQL'
UPDATE monitored_site
SET last_status = 'failed', last_error_code = :error,
locked_until = NULL,
next_assessment_at = CURRENT_TIMESTAMP + :delay * INTERVAL '1 minute'
WHERE id = :id
SQL, ['id' => $site['id'], 'error' => $e->category, 'delay' => $delay]);
}
public function dashboard(int $siteId, string $clientId): ?array
{
$row = $this->db->fetchAssociative(
'SELECT * FROM monitored_site WHERE id = :id AND client_id = :client',
['id' => $siteId, 'client' => $clientId]
);
if (!$row) {
return null;
}
foreach (['latest_findings', 'latest_tls', 'latest_recommendations'] as $field) {
$row[$field] = $row[$field] === null
? []
: json_decode($row[$field], true, 512, JSON_THROW_ON_ERROR);
}
return $row;
}
}
Pokrenite ponovnu procjenu kao zakazanu naredbu
<?php
// src/Command/ReassessWebsitesCommand.php
namespace App\Command;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use App\Security\AssessmentRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:security:reassess')]
final class ReassessWebsitesCommand extends Command
{
public function __construct(
private readonly AssessmentRepository $repository,
private readonly AnalyzerClient $analyzer,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum sites', '20');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$limit = max(1, min(100, (int) $input->getOption('limit')));
for ($processed = 0; $processed < $limit; $processed++) {
$site = $this->repository->claimOne();
if ($site === null) {
break;
}
try {
$result = $this->analyzer->analyze($site['url']);
$this->repository->saveSuccess($site, $result);
} catch (AnalyzerException $e) {
$this->repository->saveFailure($site, $e);
$this->logger->error('Website assessment failed.', [
'site_id' => $site['id'],
'category' => $e->category,
'retryable' => $e->retryable,
]);
}
}
return Command::SUCCESS;
}
}
Pokrećite naredbu svakih pet minuta; next_assessment_at svakog retka određuje njegov stvarni ritam. Na tradicionalnom hostu:
*/5 * * * * cd /srv/security-dashboard && php bin/console app:security:reassess --limit=20 --env=prod
Izložite korisničku nadzornu ploču sigurnu po vlasništvu
Upit baze podataka uključuje i ID web-stranice i ID autentificiranog korisnika. Vraćanje 404 za nepodudaranje izbjegava otkrivanje postoji li web-stranica drugog korisnika.
<?php
// src/Controller/SecurityDashboardController.php
namespace App\Controller;
use App\Security\AssessmentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CLIENT')]
final class SecurityDashboardController extends AbstractController
{
#[Route('/security-health/{siteId}', name: 'security_dashboard', methods: ['GET'])]
public function __invoke(int $siteId, AssessmentRepository $repository): Response
{
$user = $this->getUser();
$site = $repository->dashboard($siteId, $user->getUserIdentifier());
if ($site === null) {
throw $this->createNotFoundException();
}
return $this->render('security/dashboard.html.twig', ['site' => $site]);
}
}
<h2>Sigurnosno stanje web-stranice</h2>
<p>{{ site.url }}</p>
<p>Status: {{ site.last_status ?? 'Nije procijenjeno' }}</p>
{% if site.latest_assessed_at %}
<p>Posljednja procjena: {{ site.latest_assessed_at }}</p>
<p>Rezultat: {{ site.latest_score }}</p>
<h3>Nalazi</h3>
{% for severity, findings in site.latest_findings %}
<h3>{{ severity|title }}</h3>
<pre>{{ findings|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
{% endfor %}
<h3>TLS pojedinosti</h3>
<pre>{{ site.latest_tls|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
<h3>Preporuke</h3>
<pre>{{ site.latest_recommendations|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
{% endif %}
<p>Ovo je ograničeni, neinvazivni pregled javnog HTTPS-a i sigurnosnog
stanja preglednika. Nije penetracijsko testiranje ni sigurnosna certifikacija.</p>
Testirajte bez kontaktiranja usluge
MockHttpClient čini granicu determinističkom. Pokrijte uspjeh, neispravan JSON, neuspjeh autentikacije bez ponovnog pokušaja, ograničenje stope s ograničenim ponovnim pokušajima i neuspjeh transporta. Testovi preuzimanja repozitorija trebali bi se pokretati na PostgreSQL-u jer SQLite ne može reproducirati ponašanje SKIP LOCKED ni PostgreSQL JSON-a.
<?php
// tests/Security/AnalyzerClientTest.php
namespace App\Tests\Security;
use App\Security\AnalyzerClient;
use App\Security\AnalyzerException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class AnalyzerClientTest extends TestCase
{
public function testMapsAValidAssessment(): void
{
$http = new MockHttpClient(new MockResponse(json_encode([
'score' => 82,
'findings' => ['high' => [], 'medium' => [['name' => 'Example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the medium-severity finding.'],
], JSON_THROW_ON_ERROR)));
$client = new AnalyzerClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'test-token'
);
$result = $client->analyze('https://example.com');
self::assertSame(82, $result->score);
self::assertArrayHasKey('medium', $result->findingsBySeverity);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$http = new MockHttpClient(function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('{}', ['http_code' => 401]);
});
$client = new AnalyzerClient(
$http,
new NullLogger(),
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'bad-token'
);
try {
$client->analyze('https://example.com');
self::fail('Expected AnalyzerException.');
} catch (AnalyzerException $e) {
self::assertSame('authentication', $e->category);
self::assertFalse($e->retryable);
self::assertSame(1, $calls);
}
}
}
Produkcijske zaštitne mjere i česti neuspjesi
Prihvaćajte samo normalizirane https URL-ove za web-stranice koje je korisnik ovlašten nadzirati. Provjera vlasništva domene razuman je zahtjev pri upisu. Odbacite ugrađene vjerodajnice, fragmente, neispravne hostove i nepodržane sheme prije pohrane. Iako vaš poslužitelj šalje URL ograničenom udaljenom analizatoru umjesto da ga izravno dohvaća, kontrole upisa i dalje sprječavaju zloupotrebu.
- 401 ili 403: provjerite ubrizganu tajnu i oblik autentikacije; nemojte naslijepo pokušavati ponovno.
- 400 ili 422: označite cilj nevaljanim i zahtijevajte ispravak.
- 429: zadržite posljednju uspješnu snimku stanja, prikažite zastarjelost i zakažite odgođeni ponovni pokušaj.
- 5xx ili vremensko ograničenje: koristite ograničeno eksponencijalno čekanje i sačuvajte strukturiranu kategoriju neuspjeha.
- Neuspjeh ugovora: upozorite operatere; nikada ne zamjenjujte nedostajuće sigurnosne podatke izmišljenim zadanim vrijednostima.
Pratite broj uspjeha i neuspjeha, starost procjena, latenciju, broj ponovnih pokušaja i broj zakašnjelih web-stranica. Upozorite na rastući zaostatak i ponovljene neuspjehe autentikacije. Čuvajte povijesne rezultate prema deklariranoj politici zadržavanja te ograničite pristup zapisnicima i bazi podataka jer nalazi mogu otkriti obrambene slabosti.
Završni kontrolni popis za provjeru
- Pokrenite migraciju i potvrdite da aplikacija može dosegnuti PostgreSQL.
- Ubrizgajte valjan servisni token putem produkcijskog mehanizma za tajne.
- Upišite autoriziranu javnu HTTPS web-stranicu s ID-jem korisnika i intervalom procjene.
- Pokrenite
php bin/console app:security:reassess --limit=1 --env=prod. - Potvrdite uspješan redak povijesti i ažuriranu snimku stanja nadzorne ploče.
- Prijavite se kao vlasnik korisnik i provjerite rezultat, nalaze, TLS pojedinosti, preporuke i vrijeme procjene.
- Provjerite da drugi korisnik dobiva 404 za isti ID web-stranice.
- U testovima simulirajte odgovore 401, 429, neispravan JSON i vremensko ograničenje.
- Omogućite raspoređivač i upozorite kada procjene postanu zakašnjele.
Najjača značajka ove nadzorne ploče nije njezin rezultat. To je disciplinirani lanac iza tog rezultata: autorizirani cilj, ograničena procjena, provjeren ugovor, snimka stanja koja se može revidirati i poznato vrijeme osvježavanja. Taj lanac pretvara udaljeni sigurnosni signal u nešto što korisnici mogu razumjeti — i čemu operateri mogu vjerovati.