Files
portal/app/tests/Feature/Lena/StorozhaMashinyTest.php
T
Дмитрий c351b0b6f5 fix обзвон: дверь итога сторожей проверяет связность того, что приняла
Дверь принимала итог, который сам себе противоречил, и записывала «всё
хорошо». Годный по форме случай: тело говорит «сторожей 3, красных 2», а в
списке лежит одна зелёная строка — двое сторожей исчезали молча.

Дыры было две:
- длина списка storozha ни с чем не сверялась;
- поле storozhey_krasnyh не читалось вовсе — машина присылала своё
  показание, а дверь его выбрасывала.

Теперь сверяются три показания о числе сторожей — настройка, объявленное
число и длина списка — плюс объявленное число красных против нашего
подсчёта по строкам. Красных считаем ровно по машинному правилу: код не
ноль или провалов не ноль. Иначе зелёный сторож с нулём проверок давал бы
ложную тревогу, потому что портал ругается на такого, а машина его красным
не считает.

Сверх заказанного закрыты два места того же рода. Поля, которых дверь не
знает, ищутся вычитанием, а не перечислением — и в итоге, и в строке
сторожа: тогда забытое поле оказывается под защитой, а не мимо неё. И
ловится список нужной длины, набитый повторами одного имени: числа при этом
сходятся все до одного, а гоняли одного сторожа трижды.

Каждая новая проверка показана красной вырезанием своего куска по одному за
раз. Без чтения storozhey_krasnyh случай с объявленной краснотой при трёх
зелёных строках не ловит ничто — список бед выходит пустым.

Машину не трогал, схему не трогал, остальную дверь не переписывал.

Полный прогон: всего 5207, зелёных 5203, красных 0, ошибок 0, пропущено 4.
2026-08-06 23:53:05 +03:00

442 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Mail\LenaStorozhaTrevogaMail;
use App\Services\SchedulerHeartbeatTracker;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Testing\TestResponse;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
const SLOVO = 'слово-для-пробы-не-короче-тридцати-двух-знаков-0123456789';
beforeEach(function (): void {
DB::connection('pgsql_supplier')->setPdo(DB::connection('pgsql')->getPdo());
DB::connection('pgsql_supplier')->setReadPdo(DB::connection('pgsql')->getReadPdo());
config()->set('storozha_mashiny.tainoe_slovo', SLOVO);
config()->set('storozha_mashiny.adresa', '');
config()->set('storozha_mashiny.ozhidaem_storozhey', 3);
config()->set('storozha_mashiny.svezhest_chasov', 26);
config()->set('storozha_mashiny.imya_otmetki', 'lena:obhod-storozhey');
config()->set('services.monitoring.alert_email', 'ops@liderra.ru');
Mail::fake();
});
// ---------------------------------------------------------------- помощники --
/**
* Итог ровно такой формы, какую пишет sutochnyy-obhod.sh на машине.
* Числа проверок — те самые, что сторожа дают сегодня: 26 / 46 / 27.
*/
function itogSTremyaZelenymi(?string $sobranoV = null): string
{
return (string) json_encode([
'shema' => 1,
'sobrano_v' => $sobranoV ?? now()->toIso8601String(),
'mashina' => 'fhmg5qpo4jconna4s9jo',
'dlilos_ms' => 42000,
'storozhey_vsego' => 3,
'storozhey_krasnyh' => 0,
'slepok_etalonov' => str_repeat('a', 64),
'storozha' => [
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
],
], JSON_UNESCAPED_UNICODE);
}
function postuchat(string $telo, ?string $podpis = null): TestResponse
{
$podpis ??= 'sha256='.hash_hmac('sha256', $telo, SLOVO);
return test()->call(
'POST',
'/api/webhook/lena-storozha',
[], [], [],
['CONTENT_TYPE' => 'application/json', 'HTTP_X_STOROZH_PODPIS' => $podpis],
$telo,
);
}
function otmetka(): ?object
{
return DB::table('scheduler_heartbeats')->where('command_name', 'lena:obhod-storozhey')->first();
}
function polozhitOtmetku(array $polya): void
{
DB::table('scheduler_heartbeats')->insert(array_merge([
'command_name' => 'lena:obhod-storozhey',
'last_run_at' => null,
'last_success_at' => null,
'last_error' => null,
'runtime_ms' => 1000,
'consecutive_failures' => 0,
'created_at' => now(),
'updated_at' => now(),
], $polya));
}
// ============================================================ ПРИЁМ СТУКА ====
it('принимает подписанный зелёный итог и кладёт отметку', function (): void {
$otvet = postuchat(itogSTremyaZelenymi());
$otvet->assertStatus(202);
$o = otmetka();
expect($o)->not->toBeNull()
->and($o->last_error)->toBeNull()
->and((int) $o->consecutive_failures)->toBe(0);
});
it('🔴 считает возраст по дате ИЗ ИТОГА, а не по времени получения', function (): void {
// Случай мимо допущения «стук дошёл — значит сторожа отработали»: сторожа
// не запускались, а стук отправил ВЧЕРАШНИЙ файл. Отметка обязана взять
// вчерашнюю дату, иначе портал считал бы всё свежим вечно.
$vchera = now()->subHours(30);
postuchat(itogSTremyaZelenymi($vchera->toIso8601String()))->assertStatus(202);
// 🪤 Сравниваем по модулю и в секундах. Первый заход этого сторожа стоял на
// голом diffInMinutes(), а он у Carbon 3 знаковый: разница выходила -1800
// и «меньше двух» проходило ВСЕГДА. Сторож был зелёным и не сторожил
// ничего — поймано только тем, что его показали красным на подсунутой
// ошибке (подмена sobrano_v на now() в контроллере).
$o = otmetka();
expect(abs(Carbon::parse($o->last_run_at)->getTimestamp() - $vchera->getTimestamp()))
->toBeLessThan(2);
});
it('гонит стук с негодной подписью', function (): void {
postuchat(itogSTremyaZelenymi(), 'sha256='.str_repeat('f', 64))->assertStatus(404);
expect(otmetka())->toBeNull();
});
it('гонит стук вовсе без подписи', function (): void {
$telo = itogSTremyaZelenymi();
test()->call('POST', '/api/webhook/lena-storozha', [], [], [], ['CONTENT_TYPE' => 'application/json'], $telo)
->assertStatus(404);
expect(otmetka())->toBeNull();
});
it('дорога закрыта, пока тайное слово не задано', function (): void {
config()->set('storozha_mashiny.tainoe_slovo', '');
postuchat(itogSTremyaZelenymi(), 'sha256='.hash_hmac('sha256', itogSTremyaZelenymi(), ''))
->assertStatus(404);
expect(otmetka())->toBeNull();
});
it('гонит стук с чужого адреса, когда список задан', function (): void {
config()->set('storozha_mashiny.adresa', '51.250.1.97, 10.0.0.0/8');
postuchat(itogSTremyaZelenymi())->assertStatus(404);
expect(otmetka())->toBeNull();
});
it('🔴 называет ИМЯ покрасневшего сторожа', function (): void {
$telo = (string) json_encode([
'shema' => 1,
'sobrano_v' => now()->toIso8601String(),
'mashina' => 'fhmg5qpo4jconna4s9jo',
'dlilos_ms' => 40000,
'storozhey_vsego' => 3,
'storozhey_krasnyh' => 1,
'slepok_etalonov' => str_repeat('b', 64),
'storozha' => [
['imya' => 'proverka-raspisaniya', 'kod' => 1, 'proverok' => 26, 'provalov' => 3],
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
],
], JSON_UNESCAPED_UNICODE);
postuchat($telo)->assertStatus(202);
$o = otmetka();
expect($o->last_error)->toContain('proverka-raspisaniya')
->and($o->last_error)->not->toContain('proverka-chistki')
->and((int) $o->consecutive_failures)->toBe(1);
});
it('🔴 краснеет, если сторожей стало больше, чем мы ждём', function (): void {
// Случай мимо допущения «сторожей три»: на машину положили четвёртого,
// а портал по-прежнему ждёт троих. Молчать нельзя.
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozhey_vsego'] = 4;
$itog['storozha'][] = ['imya' => 'proverka-zagotovok', 'kod' => 0, 'proverok' => 23, 'provalov' => 0];
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect(otmetka()->last_error)->toContain('сторожей в обходе 4, а ждём 3');
});
it('краснеет на зелёном стороже, не сделавшем ни одной проверки', function (): void {
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozha'][1]['proverok'] = 0;
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect(otmetka()->last_error)->toContain('ничего не проверил');
});
it('краснеет на дате сборки из будущего', function (): void {
postuchat(itogSTremyaZelenymi(now()->addDays(3)->toIso8601String()))->assertStatus(202);
expect(otmetka()->last_error)->toContain('в будущем');
});
it('краснеет и протухает, когда даты сборки нет вовсе', function (): void {
$itog = json_decode(itogSTremyaZelenymi(), true);
unset($itog['sobrano_v']);
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
$o = otmetka();
expect($o->last_error)->toContain('дата сборки итога непонятна')
->and(Carbon::parse($o->last_run_at)->year)->toBe(1970);
});
it('краснеет на неизвестной разновидности итога', function (): void {
postuchat((string) json_encode(['shema' => 99, 'sobrano_v' => now()->toIso8601String()]))
->assertStatus(202);
expect(otmetka()->last_error)->toContain('непонятная разновидность');
});
// ======================================= СВЯЗНОСТЬ ТОГО, ЧТО ПРИНЯЛИ ========
//
// 🔴 Нож владельца по допущению, на котором стояла защита: «список storozha
// ПОЛОН, поэтому красноту можно пересчитать по нему, а собственное число
// красных из итога читать незачем». Оба случая ниже годны по форме —
// разновидность 1, дата свежая, подпись верна, адрес разрешён — и оба
// проходили насквозь: портал записывал «всё хорошо», хотя машина в том же
// теле прямо сказала обратное.
it('🔴 краснеет, когда в списке сторожей МЕНЬШЕ строк, чем объявлено', function (): void {
// Случай Б ножа владельца: объявлено трое, в списке ОДИН зелёный.
// Двое сторожей просто исчезли, и до починки портал молчал.
$telo = (string) json_encode([
'shema' => 1,
'sobrano_v' => now()->toIso8601String(),
'mashina' => 'fhmg5qpo4jconna4s9jo',
'dlilos_ms' => 41000,
'storozhey_vsego' => 3,
'storozhey_krasnyh' => 2,
'slepok_etalonov' => str_repeat('c', 64),
'storozha' => [
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
],
], JSON_UNESCAPED_UNICODE);
postuchat($telo)->assertStatus(202);
// 🪤 Приводим к строке нарочно. На голом toContain() непочиненная дверь даёт
// last_error = NULL, и Pest падает «Expected [iterable]» — по такому
// красному не видно, дыра это или сторож сам сломан. Со строкой красный
// честный: «'' не содержит ...».
$o = otmetka();
expect((string) $o->last_error)->toContain('строк в списке сторожей 1, а объявлено 3');
expect($o->last_success_at)->toBeNull()
->and((int) $o->consecutive_failures)->toBe(1);
});
it('🔴 краснеет, когда объявленное число красных не сходится со списком', function (): void {
// Случай В ножа владельца: машина объявила «красных 3», а все три строки
// зелёные. Портал обязан заметить, что итог сам себе противоречит, а не
// выбрасывать показание, которое ему дали.
$telo = (string) json_encode([
'shema' => 1,
'sobrano_v' => now()->toIso8601String(),
'mashina' => 'fhmg5qpo4jconna4s9jo',
'dlilos_ms' => 41000,
'storozhey_vsego' => 3,
'storozhey_krasnyh' => 3,
'slepok_etalonov' => str_repeat('d', 64),
'storozha' => [
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
],
], JSON_UNESCAPED_UNICODE);
postuchat($telo)->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('красных по списку 0, а объявлено 3');
});
it('🔴 контроль ножа: связный зелёный итог остаётся зелёным', function (): void {
// Случай А ножа владельца. Без него две проверки выше доказывали бы только
// то, что дверь научилась ругаться, — а не то, что она ругается по делу.
$otvet = postuchat(itogSTremyaZelenymi());
$otvet->assertStatus(202)->assertJson(['status' => 'принят', 'bedy' => []]);
expect(otmetka()->last_error)->toBeNull();
});
it('🔴 краснеет на поле, о котором дверь не знает', function (): void {
// Список «под защитой» считается ВЫЧИТАНИЕМ: всё, чего дверь не знает
// поимённо, — новость, о которой надо сказать вслух. Иначе поле, добавленное
// на машине и забытое здесь, окажется мимо защиты, а не под ней.
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozhey_propushcheno'] = 2;
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('storozhey_propushcheno');
});
it('🔴 краснеет на поле строки сторожа, о котором дверь не знает', function (): void {
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozha'][1]['propushcheno'] = 4;
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('propushcheno');
});
it('🔴 краснеет на список нужной длины, набитый одним и тем же сторожем', function (): void {
// Второе место того же рода, о котором нож владельца не говорил: длину
// списка можно добрать повторами. Тогда числа сходятся все до одного, а
// на деле гоняли одного сторожа трижды.
$itog = json_decode(itogSTremyaZelenymi(), true);
$itog['storozha'][1]['imya'] = 'proverka-raspisaniya';
$itog['storozha'][2]['imya'] = 'proverka-raspisaniya';
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
expect((string) otmetka()->last_error)->toContain('повторяются имена');
});
it('🔴 разбирает НАСТОЯЩЕЕ тело, снятое с машины, а не только своё выдуманное', function (): void {
// 🪤 Шов между двумя честными половинками. Все проверки выше кормят портал
// телом, которое сами же и составили. Так можно доказать что угодно и
// не заметить, что машина шлёт другое. Это тело снято 06.08.2026 с
// настоящего обхода на машине 51.250.1.97 — байт в байт то, что дошло
// до подставного слушателя на ней (проверено cmp) вместе с подписью.
$telo = <<<'JSON'
{
"shema": 1,
"sobrano_v": "2026-08-06T18:42:52Z",
"mashina": "fhmg5qpo4jconna4s9jo",
"dlilos_ms": 2000,
"storozhey_vsego": 3,
"storozhey_krasnyh": 0,
"slepok_etalonov": "df0eedf9be30e6e5f81ecd1d7f4ef46ef0901e1d4563230b79b0778ef6236b23",
"storozha": [
{"imya": "proverka-chistki", "kod": 0, "proverok": 27, "provalov": 0},
{"imya": "proverka-raspisaniya", "kod": 0, "proverok": 26, "provalov": 0},
{"imya": "proverka-tekstovogo-sleda", "kod": 0, "proverok": 46, "provalov": 0}
]
}
JSON;
postuchat($telo)->assertStatus(202);
$o = otmetka();
expect($o)->not->toBeNull()
->and($o->last_error)->toBeNull()
->and(Carbon::parse($o->last_run_at)->toIso8601String())
->toStartWith('2026-08-06T18:42:52');
// И тот же настоящий итог, состарившись, обязан поднять тревогу.
DB::table('scheduler_heartbeats')
->where('command_name', 'lena:obhod-storozhey')
->update(['last_run_at' => now()->subHours(50)]);
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertSent(LenaStorozhaTrevogaMail::class);
});
// ====================================================== ЕЖЕДНЕВНАЯ ПРОВЕРКА ==
it('при здоровье письма НЕ шлёт', function (): void {
polozhitOtmetku(['last_run_at' => now()->subHours(2), 'last_success_at' => now()->subHours(2)]);
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertNothingSent();
});
it('🔴 шлёт письмо на ПРОТУХШИЙ итог, даже если внутри всё зелено', function (): void {
// Тот самый нож: допущение «итог пришёл — значит машина жива». Итог
// пришёл ВЧЕРА и был зелёным, сегодня машина молчит. Тревога обязана быть
// от ВОЗРАСТА, а не от содержания.
polozhitOtmetku([
'last_run_at' => now()->subHours(40),
'last_success_at' => now()->subHours(40),
'last_error' => null,
]);
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
return str_contains($m->prichina, 'ПРОТУХ') && $m->vozrastChasov >= 40;
});
});
it('🔴 шлёт письмо на красного сторожа и называет его', function (): void {
polozhitOtmetku([
'last_run_at' => now()->subHours(1),
'last_error' => 'proverka-raspisaniya: код 1, проверок 26, красных 3',
'consecutive_failures' => 1,
]);
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
return str_contains($m->prichina, 'ПОКРАСНЕЛ')
&& str_contains($m->podrobno, 'proverka-raspisaniya');
});
});
it('шлёт письмо, когда итога не было ни разу', function (): void {
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
return str_contains($m->prichina, 'НЕ БЫЛО НИ РАЗУ');
});
});
it('🔴 шлёт письмо на адрес ИЗ НАСТРОЙКИ, а не на вписанный в код', function (): void {
// Решение владельца Р117. Здешняя беда, которую не повторяем: соседний
// сторож пульса шлёт на личную почту, ЖЁСТКО вписанную в его код.
config()->set('services.monitoring.alert_email', 'ops-proba@liderra.ru');
polozhitOtmetku(['last_run_at' => now()->subHours(99)]);
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
return $m->hasTo('ops-proba@liderra.ru');
});
expect(file_get_contents(app_path('Console/Commands/LenaProverkaObhodaStorozhey.php')))
->not->toContain('kdv1@bk.ru');
});
it('порог свежести берётся из настройки', function (): void {
config()->set('storozha_mashiny.svezhest_chasov', 100);
polozhitOtmetku(['last_run_at' => now()->subHours(40), 'last_success_at' => now()->subHours(40)]);
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
Mail::assertNothingSent();
});
// ======================================================== ВТОРОЙ СЛОЙ ========
it('🔴 сама ежедневная проверка стоит под присмотром пульса', function (): void {
// Иначе завели бы молчуна вторым слоем: портал ждёт стука, а что живо
// само ожидание — не проверяет никто.
expect(SchedulerHeartbeatTracker::EXPECTED_INTERVALS)
->toHaveKey('lena:proverka-obhoda');
});
it('🔴 команда стоит в расписании портала — зовущий у неё есть', function (): void {
$raspisanie = file_get_contents(base_path('routes/console.php'));
expect($raspisanie)->toContain("Schedule::command('lena:proverka-obhoda')");
});