Files
houseplan-card/test/backend-test-hygiene.test.mjs
Codex d9b6766362 test: the sys.modules guard now sees the write, not its spelling (#398)
The guard introduced by #394 matched the literal
sys.modules['custom_components... and therefore never looked at
pure_imports.py, which writes through a variable — the third instance of
the #389 class walked straight past the check created for it.

The guard now inspects the write itself and decides by the key: a whole
literal or the literal head of an f-string is safe unless it starts with
custom_components (that is how tests register homeassistant.*, hp_pure.*
and houseplan.trails); anything else — a variable, a concatenation,
setdefault/update — counts as a violation whenever the file is able to
name the package at all, i.e. contains a custom_components. literal. A
file that never names the package cannot poison it through a variable,
so restoring a snapshot stays legal.

load_pure now removes what it registered. Removing its own name is not
enough: relative imports pull neighbours in, so junction_limits leaves
wall_segment_model and coordinate_canonicalization behind. It removes
the whole custom_components difference accumulated during exec_module,
in a finally, and a repeated call still works.

pure_imports.py is a named exemption of the static guard precisely
because that guard cannot see the cleanup — so the cleanup is proven by
an executable test instead, and the mutant pure-imports-stops-cleaning
reddens it. Both mutants were run by hand.

User-Visible: no
Issue: #398
2026-08-31 03:56:01 +03:00

177 lines
12 KiB
JavaScript
Raw Permalink 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// Гигиена backend-тестов (#393): тест не имеет права оставлять после себя
// изменённую импорт-машину. В #389 это
// стоило пяти часов красного dev: пустышка в sys.modules пережила свой тест,
// Home Assistant перестал видеть у интеграции async_setup, и 85 тестов
// харнесса падали с голым `assert False`, не намекая на причину.
//
// Проверка статическая — по исходникам, без запуска. Это намеренно: рантайм
// ловит последствие и только на том порядке тестов, который сегодня сложился,
// а исходник ловит намерение.
const BACKEND = fileURLToPath(new URL('../tests_backend/', import.meta.url));
/** Файлы, которым запись в `sys.modules` разрешена поимённо (#398 AC3).
*
* Список короткий не случайно. `conftest.py` ставит пустышки родительских
* пакетов, когда Home Assistant недоступен, — по условию и осознанно (#394).
* `pure_imports.py` регистрирует загружаемый модуль ровно на время
* `exec_module` и снимает регистрацию в `finally`; статический разбор этого
* знать не может, поэтому файл назван здесь, а то, что он действительно
* убирает за собой, доказывает исполняемая проверка ниже. */
const SYS_MODULES_WRITERS = ['conftest.py', 'pure_imports.py'];
const files = () => readdirSync(BACKEND).filter((name) => name.endsWith('.py'));
const read = (name) => readFileSync(new URL(name, `file://${BACKEND}`), 'utf8');
/** Записи в `sys.modules`, про которые нельзя доказать, что пакет интеграции
* они не трогают (#398).
*
* Прежняя проверка искала строковый литерал `sys.modules['custom_components`
* и не видела `sys.modules[name] = module` — так третий заход класса #389
* проехал мимо гварда, заведённого ради него. Здесь разбирается сам факт
* записи, а решение принимается по ключу:
*
* - ключ — литерал или f-строка с литеральным началом: безопасно, если это
* начало не `custom_components` (так тесты ставят `homeassistant.*`,
* `hp_pure.*`, `houseplan.trails` — свои имена, к пакету не относящиеся);
* - ключ — выражение, либо это `setdefault`/`update`: судить о содержимом
* статически нельзя, поэтому запись считается нарушением, КОГДА файл вообще
* способен назвать пакет интеграции — то есть содержит литерал вида
* `custom_components.` (с точкой: это имя модуля, а не кусок пути).
*
* Последнее и есть fail-closed в осмысленной форме: файл, который нигде не
* упоминает имя пакета, отравить его переменной не может, а файл, который
* упоминает, обязан делать это разрешённым способом. */
function sysModulesWrites(source) {
const code = source.replace(/#[^\n]*/g, '');
const namesThePackage = /(['"])custom_components\./.test(code);
// Начало ключа, если оно доказуемо литеральное: целая строка либо f-строка
// до первой подстановки. Всё прочее (конкатенация, вызов, переменная) — не
// литерал: доказать по нему ничего нельзя.
const literalStart = (key) => {
const whole = /^(['"])((?:[^'"\\]|\\.)*)\1$/.exec(key.trim());
if (whole) return whole[2];
const fstring = /^f(['"])((?:[^'"\\{]|\\.)*)/.exec(key.trim());
return fstring ? fstring[2] : null;
};
const hits = [];
for (const match of code.matchAll(/sys\.modules\s*\[([^\]]*)\]\s*=/g)) {
const start = literalStart(match[1]);
if (start === null) { if (namesThePackage) hits.push(match[0]); continue; }
if (start.startsWith('custom_components')) hits.push(match[0]);
}
for (const match of code.matchAll(/sys\.modules\.(?:setdefault|update)\s*\(/g)) {
if (namesThePackage) hits.push(match[0]);
}
return hits;
}
test('backend-тесты не правят sys.path (#393)', () => {
const offenders = files().filter((name) => /^\s*sys\.path\b/m.test(read(name)));
assert.deepEqual(offenders, [],
'sys.path, изменённый при коллекции, действует на всю сессию: модули пакета'
+ ' становятся импортируемыми ещё и как модули верхнего уровня, и один файл'
+ ' может оказаться в sys.modules дважды с разными объектами классов.'
+ ' Читать модуль без Home Assistant можно и без этого — см. test_trails.py:'
+ ' текст файла и exec нужного среза.');
});
test('пакет интеграции подменяет только conftest и только без HA (#394)', () => {
// Пустышка вместо `custom_components.houseplan` — единственный способ читать
// подмодули без Home Assistant, и запретить её нельзя. Но место у неё одно:
// conftest, где решение принимается по честному признаку «есть ли HA».
//
// Когда её ставили сами тесты под условием «если ещё не импортирован», в CI
// она не срабатывала лишь потому, что настоящий пакет успевал импортироваться
// из файла, который идёт раньше по алфавиту. Корректность харнесса держалась
// на именах файлов; чем это кончается, показал #389.
// #398: проверка смотрит на ФАКТ записи в `sys.modules`, а не на её запись
// строковым литералом. Прежняя регулярка искала `sys.modules['custom_...`
// и не видела `sys.modules[name] = module` в pure_imports.py — третий заход
// того же класса проехал мимо гварда, заведённого ради него.
//
// Разбор грубый и fail-closed: ключ, про который нельзя доказать, что он не
// из `custom_components`, считается нарушением. Догонять формы записи
// регуляркой — бесконечная гонка, отказывать на непонятном — нет.
const offenders = files()
.filter((name) => !SYS_MODULES_WRITERS.includes(name))
.filter((name) => sysModulesWrites(read(name)).length > 0);
assert.deepEqual(offenders, [],
'запись в sys.modules живёт только в tests_backend/conftest.py (условная'
+ ' подмена пакета) и tests_backend/pure_imports.py (регистрация, снимаемая'
+ ' тем же вызовом). В остальных файлах она переживает свой тест и'
+ ' достаётся всей сессии (#389, #394, #398).');
const conftest = read('conftest.py');
assert.match(conftest, /if not HAS_HA:/,
'подмена в conftest обязана быть под условием отсутствия Home Assistant');
const stub = conftest.slice(conftest.indexOf('if not HAS_HA:'));
assert.match(stub, /sys\.modules\[_name\] = _module/);
});
// --- #398: сам гвард обязан ловить свой класс ----------------------------
// Проверка, которая не умеет краснеть, — это не проверка. Формы записи
// перечислены поимённо, потому что каждая из них уже встречалась в проекте
// или на расстоянии одной правки от встречавшейся.
const guardSees = (code) => sysModulesWrites(code).length > 0;
// Файл, который где-то называет пакет интеграции как модуль — именно то
// условие, при котором запись «неизвестно чем» становится опасной.
const PACKAGE_NAMED = 'PKG = "custom_components.houseplan"\n';
test('#398 AC1: запись через переменную не проходит мимо гварда', () => {
assert.equal(guardSees(`${PACKAGE_NAMED}sys.modules[name] = module\n`), true,
'ключ-переменная — ровно та форма, которую прежняя регулярка не видела');
});
test('#398 AC2: остальные формы записи тоже ловятся или отклоняются', () => {
for (const line of [
'sys.modules[f"custom_components.{name}"] = module',
'sys.modules["custom_" + "components.houseplan"] = module',
'sys.modules.setdefault("custom_components.houseplan", module)',
'sys.modules.update({"custom_components.houseplan": module})',
'sys.modules[ key ] = module',
]) {
assert.equal(guardSees(`${PACKAGE_NAMED}${line}\n`), true, `не поймана форма: ${line}`);
}
});
test('#398 AC2: безопасное не объявляется нарушением', () => {
for (const line of [
'sys.modules["homeassistant.core"] = core',
'sys.modules[f"hp_pure.{dep}"] = mod',
'sys.modules["houseplan.trails"] = mod',
'# sys.modules["custom_components.houseplan"] = stub # так писать нельзя',
]) {
assert.equal(guardSees(`${line}\n`), false, `ложное срабатывание на: ${line}`);
}
// Файл, который нигде не называет пакет интеграции, отравить его
// переменной не может — восстановление снимка остаётся законным.
assert.equal(guardSees('sys.modules.update(saved)\n'), false);
});
test('#398 AC3: список файлов с правом записи закрыт', () => {
assert.deepEqual(SYS_MODULES_WRITERS, ['conftest.py', 'pure_imports.py'],
'третий файл в списке — это новое исключение, а не правка: оно требует'
+ ' своего обоснования и отдельного решения, а не молчаливого добавления');
});
test('#398 AC8: pure_imports снимает регистрацию, а не обещает', () => {
// Файл в списке исключений не потому, что ему доверяют, а потому, что
// статический разбор не видит очистки. Значит очистку проверяет этот тест —
// иначе исключение стало бы дырой ровно того размера, что #389.
const source = read('pure_imports.py');
assert.match(source, /before = frozenset\(sys\.modules\)/,
'снимок берётся до загрузки');
assert.match(source, /finally:[\s\S]*del sys\.modules\[key\]/,
'разница снимается в finally — даже если exec_module бросил');
assert.match(source, /key\.startswith\(PACKAGE_ROOT\.name\)/,
'снимается вся разница под префиксом пакета, а не одно собственное имя:'
+ ' относительные импорты подтягивают соседей');
});