Foundry: проверка разрешённых и чужих действий
В предыдущем уроке handler выполнял только разрешённые действия издателя. Теперь добавим в ту же историю попытки читателя. Они должны отклоняться с точной Unauthorized и оставлять запись неизменной независимо от того, пустой курс, активный или уже отключённый.
Расширим CatalogHandler двумя методами, затем включим их селекторы в invariant-подготовку. Исходники пока не исполняются, поэтому ни найденное нарушение доступа, ни успешный смешанный прогон не заявляются. Результат главы — ясно определённые маршруты разрешённого действия и ожидаемого отказа.
Ожидаемый отказ — отдельное действие
Не следует перехватывать любой revert и считать его успехом отрицательного сценария. Например, метод с удалённой проверкой доступа может отказать из-за нулевого отпечатка. Такой отказ не подтверждает запрет читателю. Поэтому выбираем допустимые данные и точный селектор ошибки.
В базовом deactivate проверка отправителя выполняется перед проверкой активности. Значит, читатель получает Unauthorized даже для пустого номера. Этот порядок является частью текущего договора. Если новая версия сознательно меняет порядок ошибок, её совместимость с потребителями требует отдельного рассмотрения.
Handler внутри кампании должен завершить отрицательное действие нормально, если отказ реестра соответствует ожиданию. Неожиданная ошибка или отсутствие нужного отказа должны стать нарушением проверяющей программы. Политику invariant-кампании, которая не игнорирует такой сбой handler, зададим в двадцать втором уроке.
Добавляем определённого читателя
В CatalogHandler добавим константу address public constant reader = address(0xB0B). Этот адрес отличается от выбранного издателя 0xA11CE. Он задаёт один конкретный отрицательный путь в истории; более широкий набор адресов по-прежнему исследуется fuzz-функциями тринадцатого урока.
Первый метод пытается опубликовать допустимый выпуск и сравнивает текущую запись до и после. Он использует те же преобразования входа, что разрешённая публикация, поэтому различие результата связано с ролью.
function publishAsReader(uint256 rawId, bytes32 rawDigest) external {
uint256 id = bound(rawId, 1, 20);
bytes32 digest = rawDigest == bytes32(0)
? bytes32(uint256(1))
: rawDigest;
(bytes32 beforeDigest, bool beforeActive) = catalog.courses(id);
vm.expectRevert(CourseCatalog.Unauthorized.selector);
vm.prank(reader);
catalog.publish(id, digest);
(bytes32 afterDigest, bool afterActive) = catalog.courses(id);
assertEq(afterDigest, beforeDigest);
assertEq(afterActive, beforeActive);
}
Здесь ожидание неизменности получается из снимка непосредственно перед отрицательным действием. Это корректный предмет проверки сохранения состояния, но не доказательство правильности самого начального выпуска. Если реестр уже содержал неправильный хеш, сравнение до и после его не исправит и не выявит; независимая модель следующего урока предназначена для этого.
Пробуем отключить запись
Второй метод действует аналогично, но не требует предварительно активного курса. Это намеренное отличие от разрешённого отключения, которое handler пропускает для неактивного состояния. Чужой отправитель должен получать ошибку доступа при любом рассматриваемом состоянии записи.
function deactivateAsReader(uint256 rawId) external {
uint256 id = bound(rawId, 1, 20);
(bytes32 beforeDigest, bool beforeActive) = catalog.courses(id);
vm.expectRevert(CourseCatalog.Unauthorized.selector);
vm.prank(reader);
catalog.deactivate(id);
(bytes32 afterDigest, bool afterActive) = catalog.courses(id);
assertEq(afterDigest, beforeDigest);
assertEq(afterActive, beforeActive);
}
Мы не используем произвольную строку ошибки и не предполагаем, что NotActive подходит вместо Unauthorized. Вызов пустой записи читателем специально способен показать изменение порядка условий. Для издателя пустой номер изучается другим сценарием с NotActive.
Оба метода сохраняют явную границу prank: он стоит непосредственно перед обращением к реестру. Чтение снимка выполнено раньше, чтение результата — позже. Ни один getter не употребляет предназначенную для изменяющего метода одноразовую настройку отправителя.
Расширяем список целей
В setUp invariant-файла массив селекторов теперь имеет четыре элемента. Добавляем новые действия, сохраняя прежние publish и deactivate. Адрес цели остаётся address(handler), а функций инвариантов касается только наблюдение настоящего реестра.
bytes4[] memory selectors = new bytes4[](4);
selectors[0] = CatalogHandler.publish.selector;
selectors[1] = CatalogHandler.deactivate.selector;
selectors[2] = CatalogHandler.publishAsReader.selector;
selectors[3] = CatalogHandler.deactivateAsReader.selector;
Это замена фрагмента существующего setUp, а не второй локальный массив с тем же именем. После неё остаются вызовы targetContract и targetSelector из предыдущего урока. Список показывает четыре прикладных намерения, которые генератор может чередовать в одной истории.
Разбираем смешанную историю
Пусть издатель публикует первый выпуск 17, читатель пытается заменить его, издатель отключает курс, затем читатель пытается включить свой выпуск. Первая чужая попытка должна оставить (first, true), вторая — (first, false). Издатель после этого может опубликовать новый допустимый выпуск.
Такой пример важнее пары изолированных вызовов: чужие попытки происходят при разных накопленных состояниях. Но мы не заявляем, что случайная кампания обязательно выберет именно эту историю. Её можно сохранить отдельно как детерминированный сценарий, если она является важным прикладным маршрутом восстановления.
Если проверить только active после чужой замены, повреждённый digest может остаться незамеченным. Если проверить только digest после чужого отключения, незаметным останется изменение активности. Поэтому snapshot-сравнение содержит оба поля. Полный договор записи нельзя свести к одному удобному признаку.
Проверяем границу роли в конфигурации
Читатель должен отличаться от издателя не только по названию переменной. Если автор случайно задаст одинаковые адреса, отрицательный сценарий станет требовать запрета законному оператору. Ошибка такого ожидания относится к подготовке, поэтому фиксируем оба значения явно и сохраняем проверку их различия в итоговом handler.
В обычной сети пользователь может контролировать несколько адресов, а один адрес может представлять управляющую программу. Наши названия обозначают контрактные полномочия, а не разных людей. Тот же человек, отправивший вызов с другого адреса, всё равно оказывается чужим отправителем для реестра. Это полезно объяснить перед обсуждением авторизации на сайте.
Наконец, сообщение интерфейса «у вас нет прав» не заменяет полученную ошибку программы. Handler обращается к реестру напрямую и проверяет его договор, минуя отображение кнопок. Браузерный слой может дополнительно предупреждать пользователя, но источник запрета записи должен находиться в изменяющем методе. Сценарий сохраняется даже тогда, когда редакторская форма скрыта от читателя и внешне сайт выглядит правильно.
Сохраняем различие намерения и результата
Чужая попытка считается содержательным отрицательным действием, когда она достигла реестра, получила нужную ошибку и оставила состояние прежним. Ранний return из разрешённого отключения не имеет того же смысла: там реестр вообще не вызывается. В следующем блоке курса счётчики будут учитывать эти категории отдельно.
В реальной сети отказ также не обязательно означает отсутствие затрат: неуспешная включённая транзакция может расходовать газ. Наш handler моделирует прикладное состояние и причину отката, а не денежный баланс кошелька. Не делаем из его локального результата вывод о бесплатности чужих попыток.
Теперь история содержит законные переходы и явно запрещённые действия. В следующем уроке усилим сравнение независимым ожидаемым выпуском. API ошибок и отправителей доступен в Vm 1.9.7, модель кампании — в руководстве Foundry. Оглавление курса.